Compare commits

..

2 Commits

Author SHA1 Message Date
vignesh07
ef048d044c feat(workflows): add openclaw.release tweet + post workflows 2026-02-04 15:04:14 -08:00
vignesh07
7dac9e39f1 demo: add OpenClaw release tweet generator 2026-02-04 14:56:45 -08:00
116 changed files with 2780 additions and 13639 deletions

View File

@ -1,534 +0,0 @@
name: Lobster NPM Release
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to publish (for example v2026.1.21, v2026.1.21-beta.1, or fallback v2026.1.21-1)
required: true
type: string
preflight_only:
description: Run validation/build only and skip the gated publish job
required: true
default: false
type: boolean
preflight_run_id:
description: Existing successful preflight workflow run id to promote without rebuilding
required: false
type: string
npm_dist_tag:
description: npm dist-tag to publish to for stable releases
required: true
default: beta
type: choice
options:
- beta
- latest
promote_beta_to_latest:
description: Skip publish and promote the stable version already on npm beta to latest
required: true
default: false
type: boolean
concurrency:
group: lobster-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}-{2}', inputs.tag, inputs.npm_dist_tag, inputs.promote_beta_to_latest) || github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: "24.x"
PNPM_VERSION: "9.15.9"
jobs:
preflight_lobster_npm:
if: ${{ inputs.preflight_only && !inputs.promote_beta_to_latest }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Beta prerelease tags must publish to npm dist-tag beta."
exit 1
fi
- name: Forbid preflight artifact promotion on validation-only runs
if: ${{ inputs.preflight_run_id != '' }}
run: |
echo "preflight_run_id is only valid for real publish runs."
exit 1
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Setup Node environment
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
check-latest: false
- name: Setup pnpm
run: |
set -euo pipefail
corepack enable
corepack prepare "pnpm@${PNPM_VERSION}" --activate
node -v
npm -v
pnpm -v
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
PACKAGE_NAME="$(node -p "require('./package.json').name")"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
EXPECTED_VERSION="${RELEASE_TAG#v}"
if [[ "${PACKAGE_VERSION}" != "${EXPECTED_VERSION}" ]]; then
echo "package.json version ${PACKAGE_VERSION} does not match release tag ${RELEASE_TAG}."
exit 1
fi
if [[ "${PACKAGE_NAME}" != "@clawdbot/lobster" ]]; then
echo "Unexpected package name ${PACKAGE_NAME}; update the workflow if the publish target changes."
exit 1
fi
echo "Validated ${PACKAGE_NAME}@${PACKAGE_VERSION}"
- name: Validate changelog entry for release version
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
node - <<'NODE'
const fs = require('node:fs');
const releaseTag = process.env.RELEASE_TAG;
const releaseVersion = releaseTag?.startsWith('v') ? releaseTag.slice(1) : releaseTag;
if (!releaseVersion) {
console.error('RELEASE_TAG is required to validate CHANGELOG.md.');
process.exit(1);
}
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
const lines = changelog.split(/\r?\n/);
const heading = `## ${releaseVersion}`;
const start = lines.findIndex((line) => line.trim() === heading);
if (start === -1) {
console.error(`CHANGELOG.md is missing a release section for ${releaseVersion}.`);
process.exit(1);
}
let hasBullet = false;
for (let idx = start + 1; idx < lines.length; idx += 1) {
const trimmed = lines[idx].trim();
if (trimmed.startsWith('## ')) break;
if (trimmed.startsWith('- ')) {
hasBullet = true;
break;
}
}
if (!hasBullet) {
console.error(`CHANGELOG.md section ${heading} must contain at least one bullet item.`);
process.exit(1);
}
console.log(`Validated CHANGELOG.md section ${heading}`);
NODE
- name: Ensure version is not already published
env:
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
run: |
set -euo pipefail
PACKAGE_NAME="$(node -p "require('./package.json').name")"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
exit 0
fi
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
echo "Publishing ${PACKAGE_NAME}@${PACKAGE_VERSION}"
- name: Lint
run: pnpm lint
- name: Build
run: pnpm build
- name: Test
run: pnpm test
- name: Pack prepared npm tarball
id: packed_tarball
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
PACKAGE_NAME="$(node -p "require('./package.json').name")"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
PACK_JSON="$(npm pack --ignore-scripts --json)"
echo "$PACK_JSON"
PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) { process.exit(1); } process.stdout.write(first.filename); });')"
if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then
echo "npm pack did not produce a tarball file." >&2
exit 1
fi
RELEASE_SHA="$(git rev-parse HEAD)"
ARTIFACT_DIR="$RUNNER_TEMP/lobster-npm-preflight"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
cp "$PACK_PATH" "$ARTIFACT_DIR/"
printf '%s\n' "$PACKAGE_NAME" > "$ARTIFACT_DIR/package-name.txt"
printf '%s\n' "$PACKAGE_VERSION" > "$ARTIFACT_DIR/package-version.txt"
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt"
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
- name: Upload prepared npm publish bundle
uses: actions/upload-artifact@v7
with:
name: lobster-npm-preflight-${{ inputs.tag }}
path: ${{ steps.packed_tarball.outputs.dir }}
if-no-files-found: error
validate_publish_request:
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require main workflow ref for publish
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
exit 1
fi
- name: Require preflight artifact promotion on real publish
env:
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
run: |
set -euo pipefail
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2
exit 1
fi
publish_lobster_npm:
needs: [validate_publish_request]
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
actions: read
contents: read
id-token: write
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Beta prerelease tags must publish to npm dist-tag beta."
exit 1
fi
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Setup Node environment
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
check-latest: false
- name: Verify preflight run metadata
env:
GH_TOKEN: ${{ github.token }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Lobster NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`);'
- name: Download prepared npm tarball
uses: actions/download-artifact@v8
with:
name: lobster-npm-preflight-${{ inputs.tag }}
path: preflight-tarball
repository: ${{ github.repository }}
run-id: ${{ inputs.preflight_run_id }}
github-token: ${{ github.token }}
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
PACKAGE_NAME="$(node -p "require('./package.json').name")"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
EXPECTED_VERSION="${RELEASE_TAG#v}"
if [[ "${PACKAGE_VERSION}" != "${EXPECTED_VERSION}" ]]; then
echo "package.json version ${PACKAGE_VERSION} does not match release tag ${RELEASE_TAG}."
exit 1
fi
if [[ "${PACKAGE_NAME}" != "@clawdbot/lobster" ]]; then
echo "Unexpected package name ${PACKAGE_NAME}; update the workflow if the publish target changes."
exit 1
fi
- name: Validate changelog entry for release version
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
node - <<'NODE'
const fs = require('node:fs');
const releaseTag = process.env.RELEASE_TAG;
const releaseVersion = releaseTag?.startsWith('v') ? releaseTag.slice(1) : releaseTag;
if (!releaseVersion) {
console.error('RELEASE_TAG is required to validate CHANGELOG.md.');
process.exit(1);
}
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
const lines = changelog.split(/\r?\n/);
const heading = `## ${releaseVersion}`;
const start = lines.findIndex((line) => line.trim() === heading);
if (start === -1) {
console.error(`CHANGELOG.md is missing a release section for ${releaseVersion}.`);
process.exit(1);
}
let hasBullet = false;
for (let idx = start + 1; idx < lines.length; idx += 1) {
const trimmed = lines[idx].trim();
if (trimmed.startsWith('## ')) break;
if (trimmed.startsWith('- ')) {
hasBullet = true;
break;
}
}
if (!hasBullet) {
console.error(`CHANGELOG.md section ${heading} must contain at least one bullet item.`);
process.exit(1);
}
console.log(`Validated CHANGELOG.md section ${heading}`);
NODE
- name: Ensure version is not already published
run: |
set -euo pipefail
PACKAGE_NAME="$(node -p "require('./package.json').name")"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
- name: Verify prepared tarball provenance
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
EXPECTED_PACKAGE_NAME="$(node -p "require('./package.json').name")"
EXPECTED_PACKAGE_VERSION="$(node -p "require('./package.json').version")"
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
NAME_FILE="preflight-tarball/package-name.txt"
VERSION_FILE="preflight-tarball/package-version.txt"
TAG_FILE="preflight-tarball/release-tag.txt"
SHA_FILE="preflight-tarball/release-sha.txt"
NPM_DIST_TAG_FILE="preflight-tarball/release-npm-dist-tag.txt"
if [[ ! -f "$NAME_FILE" || ! -f "$VERSION_FILE" || ! -f "$TAG_FILE" || ! -f "$SHA_FILE" || ! -f "$NPM_DIST_TAG_FILE" ]]; then
echo "Prepared preflight metadata is missing." >&2
ls -la preflight-tarball >&2 || true
exit 1
fi
ARTIFACT_PACKAGE_NAME="$(tr -d '\r\n' < "$NAME_FILE")"
ARTIFACT_PACKAGE_VERSION="$(tr -d '\r\n' < "$VERSION_FILE")"
ARTIFACT_RELEASE_TAG="$(tr -d '\r\n' < "$TAG_FILE")"
ARTIFACT_RELEASE_SHA="$(tr -d '\r\n' < "$SHA_FILE")"
ARTIFACT_RELEASE_NPM_DIST_TAG="$(tr -d '\r\n' < "$NPM_DIST_TAG_FILE")"
if [[ "$ARTIFACT_PACKAGE_NAME" != "$EXPECTED_PACKAGE_NAME" ]]; then
echo "Prepared preflight package mismatch: expected $EXPECTED_PACKAGE_NAME, got $ARTIFACT_PACKAGE_NAME" >&2
exit 1
fi
if [[ "$ARTIFACT_PACKAGE_VERSION" != "$EXPECTED_PACKAGE_VERSION" ]]; then
echo "Prepared preflight version mismatch: expected $EXPECTED_PACKAGE_VERSION, got $ARTIFACT_PACKAGE_VERSION" >&2
exit 1
fi
if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then
echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2
exit 1
fi
if [[ "$ARTIFACT_RELEASE_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
echo "Prepared preflight SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $ARTIFACT_RELEASE_SHA" >&2
exit 1
fi
if [[ "$ARTIFACT_RELEASE_NPM_DIST_TAG" != "$RELEASE_NPM_DIST_TAG" ]]; then
echo "Prepared preflight npm dist-tag mismatch: expected $RELEASE_NPM_DIST_TAG, got $ARTIFACT_RELEASE_NPM_DIST_TAG" >&2
exit 1
fi
- name: Resolve publish tarball
id: publish_tarball
run: |
set -euo pipefail
TARBALL_PATH="$(find preflight-tarball -type f -name '*.tgz' -print | sort | tail -n 1)"
if [[ -z "$TARBALL_PATH" ]]; then
echo "Prepared preflight tarball not found." >&2
ls -la preflight-tarball >&2 || true
exit 1
fi
echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT"
- name: Publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$HOME/.npmrc"
npm whoami >/dev/null
publish_target="${{ steps.publish_tarball.outputs.path }}"
if [[ -n "${publish_target}" ]]; then
publish_target="./${publish_target}"
fi
npm publish "${publish_target}" --access public --tag "${RELEASE_NPM_DIST_TAG}" --provenance
promote_beta_to_latest:
if: ${{ inputs.promote_beta_to_latest }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: read
steps:
- name: Require main workflow ref for promotion
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
echo "Promotion runs must be dispatched from main."
exit 1
fi
- name: Validate promotion inputs
env:
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
echo "Promotion mode cannot run with preflight_only=true."
exit 1
fi
if [[ -n "${PREFLIGHT_RUN_ID}" ]]; then
echo "Promotion mode does not use preflight_run_id."
exit 1
fi
if [[ "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
echo "Promotion mode expects npm_dist_tag=beta because it moves beta to latest without publishing."
exit 1
fi
- name: Validate stable tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
echo "Invalid stable release tag format: ${RELEASE_TAG}" >&2
exit 1
fi
echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
- name: Setup Node environment
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
check-latest: false
- name: Validate npm dist-tags
env:
PACKAGE_NAME: "@clawdbot/lobster"
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
run: |
set -euo pipefail
beta_version="$(npm view "${PACKAGE_NAME}" dist-tags.beta)"
latest_version="$(npm view "${PACKAGE_NAME}" dist-tags.latest)"
echo "Current beta dist-tag: ${beta_version}"
echo "Current latest dist-tag: ${latest_version}"
if [[ "${beta_version}" != "${RELEASE_VERSION}" ]]; then
echo "npm beta points at ${beta_version}, expected ${RELEASE_VERSION}." >&2
exit 1
fi
if ! npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "${PACKAGE_NAME}@${RELEASE_VERSION} is not published on npm." >&2
exit 1
fi
- name: Promote beta to latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PACKAGE_NAME: "@clawdbot/lobster"
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
run: |
set -euo pipefail
printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$HOME/.npmrc"
npm whoami >/dev/null
npm dist-tag add "${PACKAGE_NAME}@${RELEASE_VERSION}" latest
promoted_latest="$(npm view "${PACKAGE_NAME}" dist-tags.latest)"
if [[ "${promoted_latest}" != "${RELEASE_VERSION}" ]]; then
echo "npm latest points at ${promoted_latest}, expected ${RELEASE_VERSION} after promotion." >&2
exit 1
fi
echo "Promoted ${PACKAGE_NAME}@${RELEASE_VERSION} from beta to latest."

View File

@ -1,35 +0,0 @@
# AGENTS.md
Guidance for coding assistants operating in this repository.
## When To Use Lobster
- Prefer `lobster` for multi-step or repeatable workflows.
- Use direct shell commands for simple one-off tasks.
- Prefer deterministic pipelines/workflows over ad-hoc LLM re-planning loops.
## Invocation Contract
- Use tool mode for machine-readable output:
- `lobster run --mode tool '<pipeline>'`
- `lobster run --mode tool --file <workflow.lobster> --args-json '<json>'`
- If `lobster` is not on `PATH`, use:
- `node bin/lobster.js ...`
## Approval And Resume
- Treat `status: "needs_approval"` as a hard stop.
- Never auto-approve on behalf of a user.
- Resume only after explicit user decision:
- `lobster resume --token <resumeToken> --approve yes|no`
## Output Handling
- Parse the tool envelope JSON fields: `ok`, `status`, `output`, `requiresApproval`, `error`.
- On `ok: false`, surface the error and stop.
## Safety And Shell Usage
- For workflow-file commands, prefer environment variables (`LOBSTER_ARG_*`) for untrusted or quoted values.
- Avoid embedding unsafe user strings directly into shell command text.

View File

@ -4,30 +4,12 @@ All notable changes to Lobster will be documented in this file.
## Unreleased
- Improve workflow resume compatibility for `stateKey` naming by accepting both `workflow_resume_` and `workflow-resume_` prefixes, including cleanup against the resolved on-disk key. Thanks to [@brownetw-ai](https://github.com/brownetw-ai) (PR [#4](https://github.com/openclaw/lobster/pull/4)).
- Add per-step workflow `retry` policies (`max`, `backoff`, `delay_ms`, `max_delay_ms`, `jitter`) with retry-aware stderr logs and dry-run visibility. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#84](https://github.com/openclaw/lobster/pull/84)).
- Add optional approval identity constraints for workflow gates (`approval.initiated_by`, `approval.required_approver`, `approval.require_different_approver`) with resume-time enforcement via `LOBSTER_APPROVAL_APPROVED_BY` and envelope metadata for integrations. Thanks to [@coolmanns](https://github.com/coolmanns) (Issue [#44](https://github.com/openclaw/lobster/issues/44)).
- Clarify `pipeline:` vs `run:` usage for `llm.invoke` / `llm_task.invoke` in workflow files, and add regression coverage to ensure `stdin: $step.stdout` is forwarded as LLM artifacts for `llm_task.invoke` pipeline steps. Thanks to [@RatkoJ](https://github.com/RatkoJ) (Issue [#41](https://github.com/openclaw/lobster/issues/41)).
- Add `lobster graph` workflow visualization with `mermaid` (default), `dot`, and `ascii` outputs, including step-type nodes, `stdin` data-flow edges, conditional dependency labels (`when`/`condition`), approval-gate diamond shapes, and `--args-json` label resolution support. Thanks to [@vignesh07](https://github.com/vignesh07) (Issue [#53](https://github.com/openclaw/lobster/issues/53)).
- Add workflow composition via `workflow:` + `workflow_args`, including recursive sub-workflow execution, cycle detection, and dry-run visibility for workflow steps. Sub-workflow approval/input halts are rejected with resume-state cleanup. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#73](https://github.com/openclaw/lobster/pull/73)).
- Add per-step `on_error` workflow policies (`stop|continue|skip_rest`) for partial-failure recovery, with structured step error fields (`error`, `errorMessage`) for condition-based branching. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#72](https://github.com/openclaw/lobster/pull/72)).
- Add per-step workflow `timeout_ms` handling, including timeout-triggered aborts, `SIGKILL` for timed shell steps, and dry-run annotations. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#74](https://github.com/openclaw/lobster/pull/74)).
- Add workflow condition comparison operators `<`, `<=`, `>`, and `>=` with strict numeric semantics (booleans/null do not coerce), including mixed boolean-expression support with `&&`/`||`. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#71](https://github.com/openclaw/lobster/pull/71)).
- Add workflow-level LLM cost tracking with `_meta.cost` summaries, per-step usage attribution, and optional `cost_limit` controls with `warn`/`stop` actions (plus custom pricing via `LOBSTER_LLM_PRICING_JSON`). Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#70](https://github.com/openclaw/lobster/pull/70)).
- Add `parallel` workflow steps with branch fan-out, `wait: all|any`, block-level timeout support, and branch result references in downstream steps. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#69](https://github.com/openclaw/lobster/pull/69)).
- Add `for_each` workflow steps for per-item sub-step execution over arrays, including loop-scoped vars (`item_var`/`index_var`), optional `batch_size` + `pause_ms`, and collected iteration outputs for downstream steps. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#68](https://github.com/openclaw/lobster/pull/68)).
- Add pipe-based template filters (for example `upper`, `length`, `join`, `default`, `date`) for the `template` command with quote-aware filter parsing and chain evaluation. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#67](https://github.com/openclaw/lobster/pull/67)).
## 2026.4.6
- Add workflow file support for `.lobster`, YAML, and JSON, including workflow args/env, native pipeline steps, and shell-safe `LOBSTER_ARG_*` inputs.
- Add structured input pauses with `ask`, workflow `input`, `needs_input`, and `lobster resume --response-json '{...}'` for resumable human-in-the-loop flows.
- Add richer workflow condition expressions with `!`, `==`, `!=`, `&&`, `||`, and parentheses.
- Export the embeddable runtime via `@clawdbot/lobster/core` so Lobster can run in-process inside OpenClaw and other hosts.
- Add generic `llm.invoke` adapters, `openclaw.invoke --each`, and keep `clawd.invoke` as a supported alias.
- Add compact state-backed workflow/pipeline resume tokens, safer resume validation, and hardened approval ID handling.
- Improve dry-run and shell interoperability with `exec --stdin raw|json|jsonl`, `approve --preview-from-stdin --limit N`, and better template/shell-variable preservation.
- Improve Windows CLI/build compatibility and fix quoted-argument parser edge cases.
- Add workflow file runner for `.lobster`/YAML/JSON workflows with args, env, conditions, and approval gates.
- Add compact workflow resume tokens backed by Lobster state storage.
- Add `exec --stdin raw|json|jsonl` to pipe workflow output into subprocess stdin.
- Add `approve --preview-from-stdin --limit N` for approval previews without extra glue.
- Add `clawd.invoke --each` to map pipeline input items into tool calls.
- Extend CLI to run workflow files with `lobster run <file>` or `--file` + `--args-json`.
## 2026.1.21-1

208
README.md
View File

@ -1,20 +1,20 @@
# Lobster
An OpenClaw-native workflow shell: typed (JSON-first) pipelines, jobs, and approval gates.
A Moltbot-native workflow shell: typed (JSON-first) pipelines, jobs, and approval gates.
## Example of Lobster at work
OpenClaw (or any other AI agent) can use `lobster` as a workflow engine and avoid re-planning every step — saving tokens while improving determinism and resumability.
## Example of lobster at work
Moltbot or any other AI agent can use `lobster` as a workflow engine and not construct a query every time - thus saving tokens, providing room for determinism, and resumability.
### Watching a PR that hasn't had changes
```
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"openclaw/openclaw\",\"pr\":1152}'"
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"moltbot/moltbot\",\"pr\":1152}'"
[
{
"kind": "github.pr.monitor",
"repo": "openclaw/openclaw",
"repo": "moltbot/moltbot",
"prNumber": 1152,
"key": "github.pr:openclaw/openclaw#1152",
"key": "github.pr:moltbot/moltbot#1152",
"changed": false,
"summary": {
"changedFields": [],
@ -36,7 +36,7 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
"state": "OPEN",
"title": "feat: Add optional lobster plugin tool (typed workflows, approvals/resume)",
"updatedAt": "2026-01-18T20:16:56Z",
"url": "https://github.com/openclaw/openclaw/pull/1152"
"url": "https://github.com/moltbot/moltbot/pull/1152"
}
}
]
@ -44,13 +44,13 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
### And a PR that has a state change (in this case an approved PR)
```
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"openclaw/openclaw\",\"pr\":1200}'"
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"moltbot/moltbot\",\"pr\":1200}'"
[
{
"kind": "github.pr.monitor",
"repo": "openclaw/openclaw",
"repo": "moltbot/moltbot",
"prNumber": 1200,
"key": "github.pr:openclaw/openclaw#1200",
"key": "github.pr:moltbot/moltbot#1200",
"changed": true,
"summary": {
"changedFields": [
@ -76,7 +76,7 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
},
"url": {
"from": null,
"to": "https://github.com/openclaw/openclaw/pull/1200"
"to": "https://github.com/moltbot/moltbot/pull/1200"
},
"state": {
"from": null,
@ -124,7 +124,7 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
"state": "MERGED",
"title": "feat(tui): add syntax highlighting for code blocks",
"updatedAt": "2026-01-19T05:06:09Z",
"url": "https://github.com/openclaw/openclaw/pull/1200"
"url": "https://github.com/moltbot/moltbot/pull/1200"
}
}
]
@ -136,7 +136,7 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
- Typed pipelines (objects/arrays), not text pipes.
- Local-first execution.
- No new auth surface: Lobster must not own OAuth/tokens.
- Composable macros that OpenClaw (or any agent) can invoke in one step to save tokens.
- Composable macros that Moltbot can invoke in one step to save tokens.
## Quick start
@ -159,20 +159,15 @@ From this folder:
- `exec --stdin raw|json|jsonl`: feed pipeline input into subprocess stdin
- `where`, `pick`, `head`: data shaping
- `json`, `table`: renderers
- `approve`: approval gate (TTY prompt or `--emit` for OpenClaw integration)
- `approve`: approval gate (TTY prompt or `--emit` for Moltbot integration)
## Next steps
- OpenClaw integration: ship as an optional OpenClaw plugin tool.
- Moltbot integration: ship as an optional Moltbot plugin tool.
## Workflow files
Lobster workflow files are meant to read like small scripts:
- `run:` or `command:` for deterministic shell/CLI steps
- `pipeline:` for native Lobster stages like `llm.invoke`
- `approval:` for hard workflow gates between steps
- `stdin: $step.stdout` or `stdin: $step.json` to pass data forward
Lobster can run YAML/JSON workflow files with `steps`, `env`, `condition`, and approval gates.
```
lobster run path/to/workflow.lobster
@ -182,162 +177,19 @@ lobster run --file path/to/workflow.lobster --args-json '{"tag":"family"}'
Example file:
```yaml
name: jacket-advice
args:
location:
default: Phoenix
name: inbox-triage
steps:
- id: fetch
run: weather --json ${location}
- id: confirm
approval: Want jacket advice from the LLM?
stdin: $fetch.json
- id: advice
pipeline: >
llm.invoke --prompt "Given this weather data, should I wear a jacket?
Be concise and return JSON."
stdin: $fetch.json
when: $confirm.approved
```
Notes:
- `run:` and `command:` are equivalent; `run:` is the preferred spelling for new files.
- `pipeline:` shares the same args/env/results model as shell steps, so later steps can still reference `$step.stdout` or `$step.json`.
- If you need a human checkpoint before an LLM call, use a dedicated `approval:` step in the workflow file rather than `approve` inside the nested pipeline.
- `cwd`, `env`, `stdin`, `when`, and `condition` work for both shell and pipeline steps.
- Use `retry`, `timeout_ms`, and `on_error` per step to control transient-failure behavior and recovery.
- Approval steps can optionally enforce identity constraints:
- `approval.required_approver` (or `requiredApprover`) requires an exact approver id.
- `approval.require_different_approver` (or `requireDifferentApprover`) requires approver id to differ from initiator.
- `approval.initiated_by` (or `initiatedBy`) sets the initiator id for comparison.
- `LOBSTER_APPROVAL_INITIATED_BY` can provide a default initiator id at run time.
- `LOBSTER_APPROVAL_APPROVED_BY` is used at resume/approval time for identity checks.
## Visualizing workflows
Use `lobster graph` to inspect workflow structure before execution.
```bash
lobster graph --file path/to/workflow.lobster
lobster graph --file path/to/workflow.lobster --format mermaid
lobster graph --file path/to/workflow.lobster --format dot
lobster graph --file path/to/workflow.lobster --format ascii
lobster graph --file path/to/workflow.lobster --args-json '{"location":"Seattle"}'
```
What gets visualized:
- each workflow step as a node (`run`, `pipeline`, `approval`, etc.)
- data-flow edges from `stdin: $step.stdout` / `$step.json` references
- conditional dependencies from `when:` / `condition:` expressions
- approval gates as diamond-shaped nodes in `mermaid` and `dot` output
Format notes:
- `mermaid` (default): emits `flowchart TD` text for GitHub/Markdown rendering
- `dot`: emits Graphviz DOT syntax
- `ascii`: emits a terminal-friendly node/edge list
## Calling LLMs from workflows
Use `llm.invoke` from a native `pipeline:` step for model-backed work:
```bash
llm.invoke --prompt 'Summarize this diff'
llm.invoke --provider openclaw --prompt 'Summarize this diff'
llm.invoke --provider pi --prompt 'Summarize this diff'
```
Provider resolution order:
- `--provider`
- `LOBSTER_LLM_PROVIDER`
- auto-detect from environment
Built-in providers today:
- `openclaw` via `OPENCLAW_URL` / `OPENCLAW_TOKEN`
- `pi` via `LOBSTER_PI_LLM_ADAPTER_URL` (typically supplied by the Pi extension)
- `http` via `LOBSTER_LLM_ADAPTER_URL`
`llm_task.invoke` remains available as a backward-compatible alias for the OpenClaw provider.
### `pipeline:` vs `run:` for LLM calls
- Use `pipeline:` for `llm.invoke` and `llm_task.invoke` (they are Lobster pipeline stages, not shell executables).
- Use `run:` only for real binaries in your shell (for example `openclaw.invoke`).
Example (`stdin` from a prior step is passed to the LLM as artifacts):
```yaml
steps:
- id: make_words
run: echo "One two three four five six"
- id: count_words
pipeline: llm_task.invoke --prompt "How many words have been pasted below?"
stdin: $make_words.stdout
```
## Calling OpenClaw tools from workflows
Shell `run:` steps execute in your system shell, so OpenClaw tool calls there must be real executables.
If you install Lobster via npm/pnpm, it installs a small shim executable named:
- `openclaw.invoke` (preferred)
- `clawd.invoke` (alias)
These shims forward to the Lobster pipeline command of the same name.
### Example: invoke llm-task
Prereqs:
- `OPENCLAW_URL` points at a running OpenClaw gateway
- optionally `OPENCLAW_TOKEN` if auth is enabled
```bash
export OPENCLAW_URL=http://127.0.0.1:18789
# export OPENCLAW_TOKEN=...
```
In a workflow:
```yaml
name: hello-world
steps:
- id: greeting
run: >
openclaw.invoke --tool llm-task --action json --args-json '{"prompt":"Hello"}'
```
### Passing data between steps (no temp files)
Use `stdin: $stepId.stdout` to pipe output from one step into the next.
## Args and shell-safety
`${arg}` substitution is a raw string replace into the shell command text.
For anything that may contain quotes, `$`, backticks, or newlines, prefer env vars:
- every resolved workflow arg is exposed as `LOBSTER_ARG_<NAME>` (uppercased, non-alnum → `_`)
- the full args object is also available as `LOBSTER_ARGS_JSON`
Example:
```yaml
args:
text:
default: ""
steps:
- id: safe
env:
TEXT: "$LOBSTER_ARG_TEXT"
command: |
jq -n --arg text "$TEXT" '{"result": $text}'
- id: collect
command: inbox list --json
- id: categorize
command: inbox categorize --json
stdin: $collect.stdout
- id: approve
command: inbox apply --approve
stdin: $categorize.stdout
approval: required
- id: execute
command: inbox apply --execute
stdin: $categorize.stdout
condition: $approve.approved
```

View File

@ -2,26 +2,26 @@
## One-liner
**Lobster is safe automation for OpenClaw — workflows that ask before acting.**
**Lobster is safe automation for Clawdbot — workflows that ask before acting.**
---
## What is Lobster?
Lobster is a workflow runtime for OpenClaw. It lets you define multi-step automations that:
Lobster is a workflow runtime for Clawdbot. It lets you define multi-step automations that:
- Run deterministically (no LLM re-planning each step)
- Halt at checkpoints and ask for approval before side effects
- Resume exactly where they left off
- Remember what they've already processed
Think of it as **IFTTT/Zapier for OpenClaw, but with human checkpoints**.
Think of it as **IFTTT/Zapier for Clawdbot, but with human checkpoints**.
---
## The Problem Lobster Solves
### Today's workflow in OpenClaw
### Today's workflow in Clawdbot
```
User: "Check my email, draft replies to anything urgent, and send them"
@ -46,7 +46,7 @@ What happens:
### With Lobster
```
OpenClaw calls: lobster.run("email.triage")
Clawdbot calls: lobster.run("email.triage")
What happens:
1. Lobster fetches emails (deterministic)
@ -63,7 +63,7 @@ Tomorrow: Lobster remembers cursor, only processes new emails
---
## Why Lobster Makes OpenClaw Better
## Why Lobster Makes Clawdbot Better
| Without Lobster | With Lobster |
|-----------------|--------------|
@ -85,7 +85,7 @@ Workflows can persist state: "last processed email ID", "last PR SHA seen", etc.
---
## How Lobster Fits with OpenClaw
## How Lobster Fits with Clawdbot
```
┌─────────────────────────────────────────────────┐
@ -95,7 +95,7 @@ Workflows can persist state: "last processed email ID", "last PR SHA seen", etc.
┌─────────────────────────────────────────────────┐
OpenClaw │
│ Clawdbot
│ - Understands intent │
│ - Chooses appropriate tool/workflow │
│ - Presents results and approval prompts │
@ -105,15 +105,15 @@ Workflows can persist state: "last processed email ID", "last PR SHA seen", etc.
┌─────────────────────────────────────────────────┐
│ Lobster │
│ - Executes deterministic pipeline │
│ - Calls OpenClaw tools (gmail, trello, etc) │
│ - Calls Clawdbot tools (gmail, trello, etc)
│ - Halts at approval checkpoints │
│ - Returns structured result + resume token │
└─────────────────────────────────────────────────┘
```
**Key insight:** Lobster doesn't replace OpenClaw. It's the execution layer that makes OpenClaw's automations safe and efficient.
**Key insight:** Lobster doesn't replace Clawdbot. It's the execution layer that makes Clawdbot's automations safe and efficient.
- **OpenClaw** = the brain (understands what you want)
- **Clawdbot** = the brain (understands what you want)
- **Lobster** = the hands (executes workflows safely)
- **Tools/Skills** = the capabilities (gmail, trello, github, etc.)
@ -121,7 +121,7 @@ Workflows can persist state: "last processed email ID", "last PR SHA seen", etc.
## Who Should Use Lobster?
### Average OpenClaw users (invisible benefit)
### Average Clawdbot users (invisible benefit)
They don't need to know Lobster exists. They just notice:
- "Set up daily email triage" works better
- Automations ask before sending/posting
@ -133,7 +133,7 @@ They can:
- Write new workflows for their specific needs
- Share workflows with the community
### The OpenClaw ecosystem
### The Clawdbot ecosystem
- Workflow recipes become a new category of shareable assets
- Skills stay simple (just expose APIs)
- Lobster handles the orchestration layer
@ -151,21 +151,21 @@ They can:
| Temporal | But 80/20 version for personal workflows |
**Best analogy for most people:**
> "Lobster is Zapier for OpenClaw, except it asks you before doing anything irreversible."
> "Lobster is Zapier for Clawdbot, except it asks you before doing anything irreversible."
---
## Why Not Build This Into OpenClaw Core?
## Why Not Build This Into Clawdbot Core?
It could be. But the plugin architecture is intentional:
1. **Core stays small**OpenClaw is already complex
1. **Core stays small** — Clawdbot is already complex
2. **Faster iteration** — Lobster can evolve without core releases
3. **Opt-in** — Not everyone needs workflow automation
4. **Community** — Easier to contribute workflows than core changes
5. **Ecosystem proof** — If plugins work for Lobster, they work for other capabilities
OpenClaw explicitly provides a plugin boundary to enable this pattern. Lobster is the first proof that it works.
Pete (Clawdbot creator) explicitly built the plugin boundary to enable this pattern. Lobster is the first proof that it works.
---
@ -221,9 +221,9 @@ Other automation tools either:
- Don't integrate with your AI assistant
Lobster is:
- Native to OpenClaw (uses the same tools you already have)
- Native to Clawdbot (uses the same tools you already have)
- Safe by default (approvals are a language primitive)
- Invisible when you want (OpenClaw uses it automatically)
- Invisible when you want (Clawdbot uses it automatically)
- Customizable when you need (write your own workflows)
---
@ -232,8 +232,8 @@ Lobster is:
- **Not a terminal replacement** — You don't switch your shell to Lobster
- **Not a general programming language** — It's for workflows, not apps
- **Not trying to replace OpenClaw** — It makes OpenClaw better
- **Not managing your secrets**OpenClaw handles auth, Lobster orchestrates
- **Not trying to replace Clawdbot** — It makes Clawdbot better
- **Not managing your secrets** — Clawdbot handles auth, Lobster orchestrates
---
@ -241,9 +241,9 @@ Lobster is:
| Question | Answer |
|----------|--------|
| What is it? | Workflow runtime for OpenClaw |
| What is it? | Workflow runtime for Clawdbot |
| One-liner? | Safe automation — workflows that ask before acting |
| Why use it? | Cheaper, safer, stateful automations |
| Who uses it? | Everyone (invisibly) or power users (directly) |
| Why not in core? | Plugin architecture — core stays small, capabilities are extensions |
| Best analogy? | Zapier for OpenClaw, but with approval checkpoints |
| Best analogy? | Zapier for Clawdbot, but with approval checkpoints |

View File

@ -1,18 +0,0 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
function shellQuote(arg) {
if (/^[A-Za-z0-9_\-./:=@]+$/.test(arg)) return arg;
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
}
const argv = process.argv.slice(2);
const pipeline = ['clawd.invoke', ...argv.map(shellQuote)].join(' ');
const res = spawnSync('lobster', [pipeline], {
stdio: 'inherit',
env: process.env,
});
process.exit(res.status ?? 1);

View File

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
@ -10,10 +10,9 @@ const __dirname = dirname(__filename);
async function load() {
const distEntry = join(__dirname, "../dist/src/cli.js");
if (existsSync(distEntry)) {
return import(pathToFileURL(distEntry).href);
return import(distEntry);
}
const srcEntry = join(__dirname, "../src/cli.js");
return import(pathToFileURL(srcEntry).href);
return import(join(__dirname, "../src/cli.js"));
}
const mod = await load();

View File

@ -1,21 +0,0 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
function shellQuote(arg) {
// Conservative POSIX-ish quoting for embedding argv into a single pipeline string.
// Lobster's pipeline parser preserves quoted substrings.
if (/^[A-Za-z0-9_\-./:=@]+$/.test(arg)) return arg;
// single-quote, escaping embedded single quotes: ' -> '\''
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
}
const argv = process.argv.slice(2);
const pipeline = ['openclaw.invoke', ...argv.map(shellQuote)].join(' ');
const res = spawnSync('lobster', [pipeline], {
stdio: 'inherit',
env: process.env,
});
process.exit(res.status ?? 1);

27
demos/README.md Normal file
View File

@ -0,0 +1,27 @@
# Lobster demos
## OpenClaw release tweet
A stage-friendly demo: read OpenClaw commits + changelog and generate a release tweet in one of three styles, with an approval gate and optional posting.
File: `demos/openclaw-release-tweet.sh`
### Prereqs
- OpenClaw repo checked out locally (default path: `../openclaw`)
- `jq`
- A running Clawdbot/Moltbot Gateway for `llm_task.invoke`:
- `CLAWD_URL` e.g. `http://127.0.0.1:19700/tools/invoke`
- `CLAWD_TOKEN` bearer token
- Optional: `bird` CLI installed + authenticated (only if `POST=true`)
### Run
```bash
cd lobster
STYLE=sassy POST=false ./demos/openclaw-release-tweet.sh
STYLE=professional POST=false ./demos/openclaw-release-tweet.sh
STYLE=drybread POST=true ./demos/openclaw-release-tweet.sh
```
### Notes
- By default it uses commits from `HEAD~30..HEAD`. Override with `SINCE_REF=<tag-or-sha>`.
- Set `LINK=https://...` to point at your release notes / site.

109
demos/openclaw-release-tweet.sh Executable file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -euo pipefail
# OpenClaw release tweet demo (Lobster)
#
# What it does:
# - Reads commits since a ref (defaults to HEAD~30 or STATE_REF if set)
# - Reads top section of CHANGELOG.md
# - Uses llm_task.invoke to generate ONE tweet in the requested style
# - Shows an approval gate
# - Optionally posts via bird CLI
#
# Prereqs:
# - OpenClaw repo checked out locally (default: ../openclaw)
# - jq installed
# - Clawdbot/Moltbot gateway reachable for llm_task.invoke via env:
# export CLAWD_URL='http://127.0.0.1:19700/tools/invoke'
# export CLAWD_TOKEN='<token>'
# - bird CLI installed+authed if POST=true
#
# Usage:
# STYLE=sassy POST=false ./demos/openclaw-release-tweet.sh
# STYLE=professional POST=false ./demos/openclaw-release-tweet.sh
# STYLE=drybread POST=true ./demos/openclaw-release-tweet.sh
STYLE=${STYLE:-professional} # sassy|professional|drybread
POST=${POST:-false} # true|false
REPO_DIR=${REPO_DIR:-../openclaw}
SINCE_REF=${SINCE_REF:-}
MAX_COMMITS=${MAX_COMMITS:-30}
LINK=${LINK:-https://openclaw.dev}
if [[ ! -d "$REPO_DIR/.git" ]]; then
echo "Repo not found: $REPO_DIR (expected a git repo). Set REPO_DIR=..." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required" >&2
exit 1
fi
if [[ "$STYLE" != "sassy" && "$STYLE" != "professional" && "$STYLE" != "drybread" ]]; then
echo "STYLE must be one of: sassy|professional|drybread" >&2
exit 1
fi
# Determine since ref
if [[ -z "$SINCE_REF" ]]; then
SINCE_REF="HEAD~${MAX_COMMITS}"
fi
COMMITS=$(cd "$REPO_DIR" && git log --no-merges --pretty=format:'%h %s (%an)' "$SINCE_REF..HEAD" | head -n 80 | jq -R -s -c 'split("\n") | map(select(length>0))')
CHANGELOG=$(cd "$REPO_DIR" && node - <<'NODE'
const fs = require('fs');
const t = fs.readFileSync('CHANGELOG.md','utf8');
const parts = t.split(/\n## /);
if (parts.length > 1) {
const top = ('## ' + parts[1]).split(/\n## /)[0];
process.stdout.write(top.slice(0, 6000));
} else {
process.stdout.write(t.slice(0, 6000));
}
NODE
)
CONTEXT=$(jq -n --arg style "$STYLE" --arg since "$SINCE_REF" --arg link "$LINK" --argjson commits "$COMMITS" --arg changelog "$CHANGELOG" '{style:$style,since:$since,link:$link,commits:$commits,changelog:$changelog}')
PIPELINE=$(cat <<'EOF'
exec --json --shell 'printf "%s" "$CONTEXT"'
| llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"}},"required":["tweet"],"additionalProperties":false}' --prompt "You are writing a release tweet for OpenClaw.\n\nInput JSON has: style (sassy|professional|drybread), since, link, commits[], changelog.\n\nWrite ONE tweet in the requested style.\nConstraints:\n- <= 260 chars\n- Include the link exactly once\n- Do not hallucinate features\n- No hashtags unless truly helpful (max 1)\n\nReturn JSON: {\"tweet\":\"...\"}\n\nINPUT:\n{{.}}"
| approve --prompt 'Post this tweet?'
EOF
)
# Render pipeline with env substitutions via bash (CONTEXT)
export CONTEXT
OUT=$(node bin/lobster.js run --mode tool "$PIPELINE")
# If needs approval, show prompt + preview
STATUS=$(echo "$OUT" | jq -r '.status')
if [[ "$STATUS" == "needs_approval" ]]; then
PROMPT=$(echo "$OUT" | jq -r '.requiresApproval.prompt')
echo "\n--- APPROVAL ---\n$PROMPT\n" >&2
if [[ "$POST" == "true" ]]; then
# Approve and resume, then post via bird.
TOKEN=$(echo "$OUT" | jq -r '.requiresApproval.resumeToken')
DONE=$(node bin/lobster.js resume --token "$TOKEN" --approve yes)
TWEET=$(echo "$DONE" | jq -r '.output[0].tweet')
echo "\nPosting via bird...\n" >&2
bird post --text "$TWEET"
echo "\nDone.\n" >&2
else
echo "(Dry-run) Not posting. Set POST=true to actually post." >&2
fi
exit 0
fi
if [[ "$STATUS" == "ok" ]]; then
echo "$OUT" | jq -r '.output[0].tweet'
exit 0
fi
echo "$OUT" | jq '.'
exit 1

View File

@ -1,29 +1,10 @@
{
"name": "@clawdbot/lobster",
"version": "2026.4.6",
"version": "2026.1.21-1",
"description": "Workflow runtime for AI agents - deterministic pipelines with approval gates",
"keywords": [
"ai-agent",
"approval",
"automation",
"lobster",
"openclaw",
"pipeline",
"workflow"
],
"homepage": "https://github.com/openclaw/lobster#readme",
"bugs": {
"url": "https://github.com/openclaw/lobster/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/openclaw/lobster.git"
},
"type": "module",
"bin": {
"clawd.invoke": "bin/clawd.invoke.js",
"lobster": "bin/lobster.js",
"openclaw.invoke": "bin/openclaw.invoke.js"
"lobster": "bin/lobster.js"
},
"files": [
"bin",
@ -32,38 +13,49 @@
"LICENSE",
"VISION.md"
],
"type": "module",
"main": "./dist/src/sdk/index.js",
"exports": {
".": "./dist/src/sdk/index.js",
"./sdk": "./dist/src/sdk/index.js",
"./core": "./dist/src/core/index.js",
"./recipes/github": "./dist/src/recipes/github/index.js"
},
"main": "./dist/src/sdk/index.js",
"scripts": {
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "pnpm clean && tsgo -p tsconfig.json",
"clean": "rm -rf dist",
"build": "pnpm clean && tsc -p tsconfig.json",
"prepack": "pnpm build",
"typecheck": "tsgo -p tsconfig.json --noEmit",
"format": "oxfmt --write package.json tsconfig.json src test",
"format:check": "oxfmt --check package.json tsconfig.json src test",
"lint": "pnpm format:check && oxlint --tsconfig tsconfig.json src test",
"fmt": "pnpm format",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "oxlint --tsconfig tsconfig.json src test",
"fmt": "oxlint --tsconfig tsconfig.json --fix src test",
"test": "pnpm build && node --test dist/test/*.test.js"
},
"dependencies": {
"ajv": "^8.20.0",
"yaml": "^2.8.4"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@typescript/native-preview": "7.0.0-dev.20260503.1",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "^0.22.1",
"typescript": "^6.0.3"
"@types/node": "^22.0.0",
"oxlint": "^0.15.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=20"
},
"keywords": [
"workflow",
"automation",
"ai-agent",
"approval",
"pipeline",
"lobster",
"clawdbot"
],
"repository": {
"type": "git",
"url": "git+https://github.com/clawdbot/lobster.git"
},
"bugs": {
"url": "https://github.com/clawdbot/lobster/issues"
},
"homepage": "https://github.com/clawdbot/lobster#readme",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",
"yaml": "^2.8.2"
}
}

618
pnpm-lock.yaml generated
View File

@ -9,359 +9,69 @@ importers:
.:
dependencies:
ajv:
specifier: ^8.20.0
version: 8.20.0
specifier: ^8.17.1
version: 8.17.1
yaml:
specifier: ^2.8.4
version: 2.8.4
specifier: ^2.8.2
version: 2.8.2
devDependencies:
'@types/node':
specifier: ^25.6.0
version: 25.6.0
'@typescript/native-preview':
specifier: 7.0.0-dev.20260503.1
version: 7.0.0-dev.20260503.1
oxfmt:
specifier: ^0.47.0
version: 0.47.0
specifier: ^22.0.0
version: 22.19.7
oxlint:
specifier: ^1.62.0
version: 1.62.0(oxlint-tsgolint@0.22.1)
oxlint-tsgolint:
specifier: ^0.22.1
version: 0.22.1
specifier: ^0.15.0
version: 0.15.15
typescript:
specifier: ^6.0.3
version: 6.0.3
specifier: ^5.7.0
version: 5.9.3
packages:
'@oxfmt/binding-android-arm-eabi@0.47.0':
resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.47.0':
resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.47.0':
resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxlint/darwin-arm64@0.15.15':
resolution: {integrity: sha512-7GOyGM6D36lUhsOvavAVpF72SycPVG0Enunx0bzv8g0+9TklzOSFN3FJlZjLst14VPdZWujZMLgkQC7tOp+Rwg==}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.47.0':
resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxlint/darwin-x64@0.15.15':
resolution: {integrity: sha512-pbrnYFwMn/fuX0z3IeQ05Nvo/b1zGxjmmWgkrQSDwYHxBxP6NT41hk1pmqkcA+v53xk9wvOa/6vBBI/U30F8Ow==}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.47.0':
resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxlint/linux-arm64-gnu@0.15.15':
resolution: {integrity: sha512-QWjG3YVsDlIvDTBUPmtPiyqP34ZQpFJqQh2JO94pBih11lFxQ0IGVMEXDhmW3WdiSFPZSJsZGzWynalM9eg+RA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxlint/linux-arm64-musl@0.15.15':
resolution: {integrity: sha512-4W0YsmMSbNzzExOWhk+6zNfmJEmKFqSjFIn8CKLtYFvH8kF6KjoW4/0HNsDNYW5Fz+KOut/2JgkvxAiKH+r0zA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxlint/linux-x64-gnu@0.15.15':
resolution: {integrity: sha512-agP3e+eQ6tE5tqN6VI4Uukx2yvjwYFjtrDMcB19J7PmGOaFRwuMuT0sNWK/9guvhuS9aCINNZTi3kEhMy9Qgng==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxlint/linux-x64-musl@0.15.15':
resolution: {integrity: sha512-L2qE9NhhUafsJOO4pofLx/0hW5IB0sfJa6bS85q0j+ySaI0f3CxMaAadrZLFSuqHWB3oF18B5yvzaPWsc2ohbQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxlint/win32-arm64@0.15.15':
resolution: {integrity: sha512-B7f4VAS/E78n8zy6XZlNeyYOtWTel4BJn/22Ap2yEAlNzO34ot8dGfpLk6MqTUWJrRnARwVBVmc3wRVrsOT5yg==}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.47.0':
resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxlint/win32-x64@0.15.15':
resolution: {integrity: sha512-ZM9T3/OpaQ3qvrk/VuHO2EQmhNH4cOZdr/b/Ju9VKwBr+ahhqMn3W5srrplWQWxfsb0yd1yBj7iD0jdAps2iLg==}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.22.1':
resolution: {integrity: sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==}
cpu: [arm64]
os: [darwin]
'@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
'@oxlint-tsgolint/darwin-x64@0.22.1':
resolution: {integrity: sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.22.1':
resolution: {integrity: sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.22.1':
resolution: {integrity: sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.22.1':
resolution: {integrity: sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.22.1':
resolution: {integrity: sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.62.0':
resolution: {integrity: sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.62.0':
resolution: {integrity: sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.62.0':
resolution: {integrity: sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.62.0':
resolution: {integrity: sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.62.0':
resolution: {integrity: sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.62.0':
resolution: {integrity: sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.62.0':
resolution: {integrity: sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.62.0':
resolution: {integrity: sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.62.0':
resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.62.0':
resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.62.0':
resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.62.0':
resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.62.0':
resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.62.0':
resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.62.0':
resolution: {integrity: sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.62.0':
resolution: {integrity: sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.62.0':
resolution: {integrity: sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-rUZQVuBcZlxADagx+pnhDHqjX2Ewh+KWave6vtilRWR5vsGyR3FaCzPFqXteiwPg6XRijEMnMaXg60t5itm8EA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-YtK8ac8RCkRh7jwoP8Wt4LaBhpGK8cOVMi3e0cvF/8Xn5XV1ewJK3/HdNBnlDhCib3j0eVRxK/Pg9GIq/hFUxQ==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-c5yM3Ea2WZSLKmscMxUhEDdaR2Ugp7P9CX6osciIPgZIF9c4LDiuKQohjQAyidx9JZKCsSXnfS88QssWH/+ONQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-28vAomYeU8hpz1f0dDoWz6PYehz0ffEfkJhFq4tqXzUM/QY1I15u5y03EJQ5UcP4LRwrdJe22J5LdrYpM2Lr3w==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-M64z7LwpqNfOXYCBKmD/ObwyxYOobUk4tDv0ECNLit7pDER1sswNZjJGjgRYjQsKokmydy6p3FqtJ1uUPUP/sw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-QHy3R1VBb8MYszjpz0IyuDjKaW6JUHg8hYEqzhZIxvrydKLF3gyDeKohnmWSFTS/oII0GvUmYM3h83fbanBvcQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-nrapqpVONrg0IZjKp3AzV9M+jxPwKGTQZEOxuKh6WHMhy/UElN8RnOFlfetA8ZMtKvPkmjgjqw0qoAa5QMwhPA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-gDro38CPFiBUGbaFGNt+ufOsEd1OrZrfrOPxsLSfBcvvoGaqAxV++ul/BHTOShoEkIYHiFsoDX2az1IPCDV2jQ==}
engines: {node: '>=16.20.0'}
hasBin: true
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -372,216 +82,59 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
oxfmt@0.47.0:
resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==}
engines: {node: ^20.19.0 || >=22.12.0}
oxlint@0.15.15:
resolution: {integrity: sha512-oQNc1mAHrrbKiXyKJMGs9VCZfwGfLy7YiQKa4qupi71X/u4xyWqOh36YKXqWOXnmm2y7vfWFpGZlhJPAa9tMqA==}
engines: {node: '>=8.*'}
hasBin: true
oxlint-tsgolint@0.22.1:
resolution: {integrity: sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==}
hasBin: true
oxlint@1.62.0:
resolution: {integrity: sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.18.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
tinypool@2.1.0:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots:
'@oxfmt/binding-android-arm-eabi@0.47.0':
'@oxlint/darwin-arm64@0.15.15':
optional: true
'@oxfmt/binding-android-arm64@0.47.0':
'@oxlint/darwin-x64@0.15.15':
optional: true
'@oxfmt/binding-darwin-arm64@0.47.0':
'@oxlint/linux-arm64-gnu@0.15.15':
optional: true
'@oxfmt/binding-darwin-x64@0.47.0':
'@oxlint/linux-arm64-musl@0.15.15':
optional: true
'@oxfmt/binding-freebsd-x64@0.47.0':
'@oxlint/linux-x64-gnu@0.15.15':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
'@oxlint/linux-x64-musl@0.15.15':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
'@oxlint/win32-arm64@0.15.15':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
'@oxlint/win32-x64@0.15.15':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.47.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.47.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.47.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.47.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.47.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.22.1':
optional: true
'@oxlint-tsgolint/darwin-x64@0.22.1':
optional: true
'@oxlint-tsgolint/linux-arm64@0.22.1':
optional: true
'@oxlint-tsgolint/linux-x64@0.22.1':
optional: true
'@oxlint-tsgolint/win32-arm64@0.22.1':
optional: true
'@oxlint-tsgolint/win32-x64@0.22.1':
optional: true
'@oxlint/binding-android-arm-eabi@1.62.0':
optional: true
'@oxlint/binding-android-arm64@1.62.0':
optional: true
'@oxlint/binding-darwin-arm64@1.62.0':
optional: true
'@oxlint/binding-darwin-x64@1.62.0':
optional: true
'@oxlint/binding-freebsd-x64@1.62.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.62.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.62.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.62.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.62.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.62.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.62.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.62.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.62.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.62.0':
optional: true
'@types/node@25.6.0':
'@types/node@22.19.7':
dependencies:
undici-types: 7.19.2
undici-types: 6.21.0
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview@7.0.0-dev.20260503.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260503.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260503.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260503.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260503.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260503.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260503.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260503.1
ajv@8.20.0:
ajv@8.17.1:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
@ -594,68 +147,21 @@ snapshots:
json-schema-traverse@1.0.0: {}
oxfmt@0.47.0:
dependencies:
tinypool: 2.1.0
oxlint@0.15.15:
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.47.0
'@oxfmt/binding-android-arm64': 0.47.0
'@oxfmt/binding-darwin-arm64': 0.47.0
'@oxfmt/binding-darwin-x64': 0.47.0
'@oxfmt/binding-freebsd-x64': 0.47.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.47.0
'@oxfmt/binding-linux-arm-musleabihf': 0.47.0
'@oxfmt/binding-linux-arm64-gnu': 0.47.0
'@oxfmt/binding-linux-arm64-musl': 0.47.0
'@oxfmt/binding-linux-ppc64-gnu': 0.47.0
'@oxfmt/binding-linux-riscv64-gnu': 0.47.0
'@oxfmt/binding-linux-riscv64-musl': 0.47.0
'@oxfmt/binding-linux-s390x-gnu': 0.47.0
'@oxfmt/binding-linux-x64-gnu': 0.47.0
'@oxfmt/binding-linux-x64-musl': 0.47.0
'@oxfmt/binding-openharmony-arm64': 0.47.0
'@oxfmt/binding-win32-arm64-msvc': 0.47.0
'@oxfmt/binding-win32-ia32-msvc': 0.47.0
'@oxfmt/binding-win32-x64-msvc': 0.47.0
oxlint-tsgolint@0.22.1:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.22.1
'@oxlint-tsgolint/darwin-x64': 0.22.1
'@oxlint-tsgolint/linux-arm64': 0.22.1
'@oxlint-tsgolint/linux-x64': 0.22.1
'@oxlint-tsgolint/win32-arm64': 0.22.1
'@oxlint-tsgolint/win32-x64': 0.22.1
oxlint@1.62.0(oxlint-tsgolint@0.22.1):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.62.0
'@oxlint/binding-android-arm64': 1.62.0
'@oxlint/binding-darwin-arm64': 1.62.0
'@oxlint/binding-darwin-x64': 1.62.0
'@oxlint/binding-freebsd-x64': 1.62.0
'@oxlint/binding-linux-arm-gnueabihf': 1.62.0
'@oxlint/binding-linux-arm-musleabihf': 1.62.0
'@oxlint/binding-linux-arm64-gnu': 1.62.0
'@oxlint/binding-linux-arm64-musl': 1.62.0
'@oxlint/binding-linux-ppc64-gnu': 1.62.0
'@oxlint/binding-linux-riscv64-gnu': 1.62.0
'@oxlint/binding-linux-riscv64-musl': 1.62.0
'@oxlint/binding-linux-s390x-gnu': 1.62.0
'@oxlint/binding-linux-x64-gnu': 1.62.0
'@oxlint/binding-linux-x64-musl': 1.62.0
'@oxlint/binding-openharmony-arm64': 1.62.0
'@oxlint/binding-win32-arm64-msvc': 1.62.0
'@oxlint/binding-win32-ia32-msvc': 1.62.0
'@oxlint/binding-win32-x64-msvc': 1.62.0
oxlint-tsgolint: 0.22.1
'@oxlint/darwin-arm64': 0.15.15
'@oxlint/darwin-x64': 0.15.15
'@oxlint/linux-arm64-gnu': 0.15.15
'@oxlint/linux-arm64-musl': 0.15.15
'@oxlint/linux-x64-gnu': 0.15.15
'@oxlint/linux-x64-musl': 0.15.15
'@oxlint/win32-arm64': 0.15.15
'@oxlint/win32-x64': 0.15.15
require-from-string@2.0.2: {}
tinypool@2.1.0: {}
typescript@5.9.3: {}
typescript@6.0.3: {}
undici-types@6.21.0: {}
undici-types@7.19.2: {}
yaml@2.8.4: {}
yaml@2.8.2: {}

View File

@ -1,32 +1,19 @@
import { parsePipeline } from "./parser.js";
import { createDefaultRegistry } from "./commands/registry.js";
import { runPipeline } from "./runtime.js";
import { decodeResumeToken, parseResumeArgs, resolveApprovalId } from "./resume.js";
import { cleanupApprovalIndexByStateKey, deleteApprovalId } from "./state/store.js";
import {
WorkflowResumeArgumentError,
loadWorkflowFile,
resolveWorkflowArgs,
runWorkflowFile,
} from "./workflows/file.js";
import { renderWorkflowGraph } from "./workflows/graph.js";
import type { WorkflowGraphFormat } from "./workflows/graph.js";
import { deleteStateJson } from "./state/store.js";
import {
finalizePipelineToolRun,
loadPipelineResumeState,
validatePipelineInputResponse,
} from "./pipeline_resume_state.js";
import { parsePipeline } from './parser.js';
import { createDefaultRegistry } from './commands/registry.js';
import { runPipeline } from './runtime.js';
import { encodeToken } from './token.js';
import { decodeResumeToken, parseResumeArgs } from './resume.js';
import { runWorkflowFile } from './workflows/file.js';
export async function runCli(argv) {
const registry = createDefaultRegistry();
if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
process.stdout.write(helpText());
return;
}
if (argv[0] === "help") {
if (argv[0] === 'help') {
const topic = argv[1];
if (!topic) {
process.stdout.write(helpText());
@ -42,27 +29,22 @@ export async function runCli(argv) {
return;
}
if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
if (argv[0] === 'version' || argv[0] === '--version' || argv[0] === '-v') {
process.stdout.write(`${await readVersion()}\n`);
return;
}
if (argv[0] === "doctor") {
if (argv[0] === 'doctor') {
await handleDoctor({ argv: argv.slice(1), registry });
return;
}
if (argv[0] === "graph") {
await handleGraph({ argv: argv.slice(1) });
return;
}
if (argv[0] === "run") {
if (argv[0] === 'run') {
await handleRun({ argv: argv.slice(1), registry });
return;
}
if (argv[0] === "resume") {
if (argv[0] === 'resume') {
await handleResume({ argv: argv.slice(1), registry });
return;
}
@ -71,72 +53,9 @@ export async function runCli(argv) {
await handleRun({ argv, registry });
}
async function handleGraph({ argv }) {
const parsed = parseGraphArgs(argv);
if (parsed.help) {
process.stdout.write(graphHelpText());
return;
}
if (!parsed.filePath) {
process.stderr.write("graph requires a workflow file path (use --file <path>)\n");
process.exitCode = 2;
return;
}
if (!isWorkflowGraphFormat(parsed.format)) {
process.stderr.write("graph --format must be one of: mermaid, dot, ascii\n");
process.exitCode = 2;
return;
}
let argsJson: Record<string, unknown> = {};
if (parsed.argsJson) {
try {
const value = JSON.parse(parsed.argsJson);
if (!value || typeof value !== "object" || Array.isArray(value)) {
process.stderr.write("graph --args-json must be a JSON object\n");
process.exitCode = 2;
return;
}
argsJson = value as Record<string, unknown>;
} catch {
process.stderr.write("graph --args-json must be valid JSON\n");
process.exitCode = 2;
return;
}
}
let filePath: string;
try {
filePath = await resolveWorkflowFile(parsed.filePath);
} catch (err) {
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
return;
}
try {
const workflow = await loadWorkflowFile(filePath);
const args = resolveWorkflowArgs(workflow.args, argsJson);
const graph = renderWorkflowGraph({ workflow, format: parsed.format, args });
process.stdout.write(graph);
process.stdout.write("\n");
} catch (err) {
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
}
}
function isWorkflowGraphFormat(value: string): value is WorkflowGraphFormat {
return value === "mermaid" || value === "dot" || value === "ascii";
}
async function handleRun({ argv, registry }) {
const parsed = parseRunArgs(argv);
const { mode, argsJson } = parsed;
const { mode, rest, filePath, argsJson } = parseRunArgs(argv);
const normalizedMode = normalizeMode(mode);
const { rest, filePath, dryRun } = await resolveRunTarget(parsed);
const workflowFile = filePath
? await resolveWorkflowFile(filePath)
@ -147,15 +66,12 @@ async function handleRun({ argv, registry }) {
try {
parsedArgs = JSON.parse(argsJson);
} catch {
if (mode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: "run --args-json must be valid JSON" },
});
if (mode === 'tool') {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: 'run --args-json must be valid JSON' } });
process.exitCode = 2;
return;
}
process.stderr.write("run --args-json must be valid JSON\n");
process.stderr.write('run --args-json must be valid JSON\n');
process.exitCode = 2;
return;
}
@ -171,72 +87,37 @@ async function handleRun({ argv, registry }) {
stderr: process.stderr,
env: process.env,
mode: normalizedMode,
registry,
dryRun,
},
});
if (normalizedMode === "tool") {
if (output.status === "needs_approval") {
if (normalizedMode === 'tool') {
if (output.status === 'needs_approval') {
writeToolEnvelope({
ok: true,
status: "needs_approval",
status: 'needs_approval',
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: null,
});
return;
}
if (output.status === "needs_input") {
writeToolEnvelope({
ok: true,
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: output.requiresInput ?? null,
});
return;
}
writeToolEnvelope({
ok: true,
status: "ok",
status: 'ok',
output: output.output,
requiresApproval: null,
requiresInput: null,
});
return;
}
if (output.status === "needs_approval" || output.status === "needs_input") {
process.stdout.write(
JSON.stringify(
{
status: output.status,
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: output.requiresInput ?? null,
},
null,
2,
),
);
process.stdout.write("\n");
return;
}
if (output.status === "ok" && output.output.length) {
if (output.status === 'ok' && output.output.length) {
process.stdout.write(JSON.stringify(output.output, null, 2));
process.stdout.write("\n");
process.stdout.write('\n');
}
return;
} catch (err) {
if (normalizedMode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
if (normalizedMode === 'tool') {
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
process.exitCode = 1;
return;
}
@ -246,17 +127,14 @@ async function handleRun({ argv, registry }) {
}
}
const pipelineString = rest.join(" ");
const pipelineString = rest.join(' ');
let pipeline;
try {
pipeline = parsePipeline(pipelineString);
} catch (err) {
if (mode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: err?.message ?? String(err) },
});
if (mode === 'tool') {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: err?.message ?? String(err) } });
process.exitCode = 2;
return;
}
@ -275,21 +153,40 @@ async function handleRun({ argv, registry }) {
stderr: process.stderr,
env: process.env,
mode: normalizedMode,
dryRun,
});
if (normalizedMode === "tool") {
const finalized = await finalizePipelineToolRun({
env: process.env,
pipeline,
output,
});
if (normalizedMode === 'tool') {
const approval = output.halted && output.items.length === 1 && output.items[0]?.type === 'approval_request'
? output.items[0]
: null;
if (approval) {
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
pipeline,
resumeAtIndex: (output.haltedAt?.index ?? -1) + 1,
items: approval.items,
prompt: approval.prompt,
});
writeToolEnvelope({
ok: true,
status: 'needs_approval',
output: [],
requiresApproval: {
...approval,
resumeToken,
},
});
return;
}
writeToolEnvelope({
ok: true,
status: finalized.status,
output: finalized.output,
requiresApproval: finalized.requiresApproval,
requiresInput: finalized.requiresInput,
status: 'ok',
output: output.items,
requiresApproval: null,
});
return;
}
@ -297,14 +194,11 @@ async function handleRun({ argv, registry }) {
// Human mode: if the last command didn't render, print JSON.
if (!output.rendered) {
process.stdout.write(JSON.stringify(output.items, null, 2));
process.stdout.write("\n");
process.stdout.write('\n');
}
} catch (err) {
if (normalizedMode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
if (normalizedMode === 'tool') {
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
process.exitCode = 1;
return;
}
@ -315,24 +209,14 @@ async function handleRun({ argv, registry }) {
function parseRunArgs(argv) {
const rest = [];
let mode = "human";
let mode = 'human';
let filePath = null;
let argsJson = null;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
// Treat --dry-run as a Lobster flag only before positional command/pipeline
// args begin. Once rest has started, the token may belong to the command.
// Trailing workflow-file --dry-run is handled later after we can prove the
// first positional token is actually a workflow file.
if (tok === "--dry-run" && rest.length === 0) {
dryRun = true;
continue;
}
if (tok === "--mode") {
if (tok === '--mode') {
const value = argv[i + 1];
if (value) {
mode = value;
@ -341,12 +225,12 @@ function parseRunArgs(argv) {
continue;
}
if (tok.startsWith("--mode=")) {
mode = tok.slice("--mode=".length) || "human";
if (tok.startsWith('--mode=')) {
mode = tok.slice('--mode='.length) || 'human';
continue;
}
if (tok === "--file") {
if (tok === '--file') {
const value = argv[i + 1];
if (value) {
filePath = value;
@ -355,12 +239,12 @@ function parseRunArgs(argv) {
continue;
}
if (tok.startsWith("--file=")) {
filePath = tok.slice("--file=".length);
if (tok.startsWith('--file=')) {
filePath = tok.slice('--file='.length);
continue;
}
if (tok === "--args-json") {
if (tok === '--args-json') {
const value = argv[i + 1];
if (value) {
argsJson = value;
@ -369,109 +253,25 @@ function parseRunArgs(argv) {
continue;
}
if (tok.startsWith("--args-json=")) {
argsJson = tok.slice("--args-json=".length);
if (tok.startsWith('--args-json=')) {
argsJson = tok.slice('--args-json='.length);
continue;
}
rest.push(tok);
}
return { mode, rest, filePath, argsJson, dryRun };
}
function parseGraphArgs(argv: string[]) {
const rest: string[] = [];
let filePath: string | null = null;
let format = "mermaid";
let argsJson: string | null = null;
let help = false;
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === "-h" || tok === "--help") {
help = true;
continue;
}
if (tok === "--file") {
const value = argv[i + 1];
if (value) {
filePath = value;
i++;
}
continue;
}
if (tok.startsWith("--file=")) {
filePath = tok.slice("--file=".length);
continue;
}
if (tok === "--format") {
const value = argv[i + 1];
if (value) {
format = value;
i++;
}
continue;
}
if (tok.startsWith("--format=")) {
format = tok.slice("--format=".length) || "mermaid";
continue;
}
if (tok === "--args-json") {
const value = argv[i + 1];
if (value) {
argsJson = value;
i++;
}
continue;
}
if (tok.startsWith("--args-json=")) {
argsJson = tok.slice("--args-json=".length);
continue;
}
rest.push(tok);
}
if (!filePath && rest.length > 0) {
filePath = rest[0];
}
return { filePath, format, argsJson, help };
}
async function resolveRunTarget(parsed: {
rest: string[];
filePath: string | null;
dryRun: boolean;
}) {
if (parsed.filePath) return parsed;
const restWithoutDryRun = parsed.rest.filter((token) => token !== "--dry-run");
if (restWithoutDryRun.length === 1 && restWithoutDryRun.length !== parsed.rest.length) {
try {
const workflowFile = await resolveWorkflowFile(restWithoutDryRun[0]);
return { ...parsed, filePath: workflowFile, rest: [], dryRun: true };
} catch {
return parsed;
}
}
return parsed;
return { mode, rest, filePath, argsJson };
}
function normalizeMode(mode) {
return mode === "tool" ? "tool" : "human";
return mode === 'tool' ? 'tool' : 'human';
}
async function detectWorkflowFile(rest) {
if (rest.length !== 1) return null;
const candidate = rest[0];
if (!candidate || candidate.includes("|")) return null;
if (!candidate || candidate.includes('|')) return null;
try {
return await resolveWorkflowFile(candidate);
} catch {
@ -480,79 +280,31 @@ async function detectWorkflowFile(rest) {
}
async function resolveWorkflowFile(candidate) {
const { promises: fsp } = await import("node:fs");
const { resolve, extname, isAbsolute } = await import("node:path");
const { promises: fsp } = await import('node:fs');
const { resolve, extname, isAbsolute } = await import('node:path');
const resolved = isAbsolute(candidate) ? candidate : resolve(process.cwd(), candidate);
const stat = await fsp.stat(resolved);
if (!stat.isFile()) throw new Error("Workflow path is not a file");
if (!stat.isFile()) throw new Error('Workflow path is not a file');
const ext = extname(resolved).toLowerCase();
if (![".lobster", ".yaml", ".yml", ".json"].includes(ext)) {
throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json");
if (!['.lobster', '.yaml', '.yml', '.json'].includes(ext)) {
throw new Error('Workflow file must end in .lobster, .yaml, .yml, or .json');
}
return resolved;
}
async function handleResume({ argv, registry }) {
const mode = "tool";
let approved: boolean | undefined;
let response: unknown = undefined;
let cancel = false;
let payload: any;
let resolvedApprovalId: string | null = null;
try {
const parsed = parseResumeArgs(argv);
approved = parsed.approved;
response = parsed.response;
cancel = parsed.cancel === true;
resolvedApprovalId = parsed.approvalId;
const mode = 'tool';
const { token, approved } = parseResumeArgs(argv);
const payload = decodeResumeToken(token);
// Resolve short approval ID to token if provided
let token: string;
if (parsed.approvalId) {
token = await resolveApprovalId(parsed.approvalId, process.env);
} else {
token = parsed.token!;
}
payload = decodeResumeToken(token);
} catch (err) {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: err?.message ?? String(err) },
});
process.exitCode = 2;
if (!approved) {
writeToolEnvelope({ ok: true, status: 'cancelled', output: [], requiresApproval: null });
return;
}
// Helper: clean up approval ID index after successful use
const cleanupIndex = async () => {
if (resolvedApprovalId) {
await deleteApprovalId({ env: process.env, approvalId: resolvedApprovalId });
} else if (payload.stateKey) {
await cleanupApprovalIndexByStateKey({ env: process.env, stateKey: payload.stateKey });
}
};
if (cancel === true) {
await cleanupIndex();
if (payload.kind === "workflow-file" && payload.stateKey) {
await deleteStateJson({ env: process.env, key: payload.stateKey });
}
if (payload.kind === "pipeline-resume" && payload.stateKey) {
await deleteStateJson({ env: process.env, key: payload.stateKey });
}
writeToolEnvelope({
ok: true,
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
});
return;
}
if (payload.kind === "workflow-file") {
if (payload.kind === 'workflow-file') {
try {
const output = await runWorkflowFile({
filePath: payload.filePath,
@ -561,146 +313,35 @@ async function handleResume({ argv, registry }) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: "tool",
registry,
mode: 'tool',
},
resume: payload,
approved,
response,
cancel,
approved: true,
});
if (output.status === "needs_approval") {
if (output.status === 'needs_approval') {
writeToolEnvelope({
ok: true,
status: "needs_approval",
status: 'needs_approval',
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: null,
});
return;
}
if (output.status === "needs_input") {
writeToolEnvelope({
ok: true,
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: output.requiresInput ?? null,
});
return;
}
await cleanupIndex();
if (output.status === "cancelled") {
writeToolEnvelope({
ok: true,
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
});
return;
}
writeToolEnvelope({
ok: true,
status: "ok",
output: output.output,
requiresApproval: null,
requiresInput: null,
});
writeToolEnvelope({ ok: true, status: 'ok', output: output.output, requiresApproval: null });
return;
} catch (err) {
if (err instanceof WorkflowResumeArgumentError) {
writeToolEnvelope({ ok: false, error: { type: "parse_error", message: err.message } });
process.exitCode = 2;
return;
}
// Don't clean up index on error — allow retry by --id
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
process.exitCode = 1;
return;
}
}
const previousStateKey = payload.stateKey;
let resumeState;
try {
resumeState = await loadPipelineResumeState(process.env, previousStateKey);
} catch (err) {
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
process.exitCode = 1;
return;
}
if (resumeState.haltType === "input_request") {
if (approved !== undefined) {
writeToolEnvelope({
ok: false,
error: {
type: "parse_error",
message: "pipeline input resumes require --response-json <json>",
},
});
process.exitCode = 2;
return;
}
if (response === undefined) {
writeToolEnvelope({
ok: false,
error: {
type: "parse_error",
message: "pipeline input resumes require --response-json <json>",
},
});
process.exitCode = 2;
return;
}
try {
validatePipelineInputResponse(resumeState.inputSchema, response);
} catch (err) {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: err?.message ?? String(err) },
});
process.exitCode = 2;
return;
}
} else {
if (response !== undefined) {
writeToolEnvelope({
ok: false,
error: {
type: "parse_error",
message: "approval resumes require --approve yes|no, not --response-json",
},
});
process.exitCode = 2;
return;
}
if (approved !== true) {
await cleanupIndex();
await deleteStateJson({ env: process.env, key: previousStateKey });
writeToolEnvelope({
ok: true,
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
});
return;
}
}
const remaining = resumeState.pipeline.slice(resumeState.resumeAtIndex);
const input = streamFromItems(
resumeState.haltType === "input_request" ? [response] : resumeState.items,
);
const remaining = payload.pipeline.slice(payload.resumeAtIndex);
const input = (async function* () {
for (const item of payload.items) yield item;
})();
try {
const output = await runPipeline({
@ -713,49 +354,50 @@ async function handleResume({ argv, registry }) {
mode,
input,
});
await cleanupIndex();
const finalized = await finalizePipelineToolRun({
env: process.env,
pipeline: remaining,
output,
previousStateKey,
});
writeToolEnvelope({
ok: true,
status: finalized.status,
output: finalized.output,
requiresApproval: finalized.requiresApproval,
requiresInput: finalized.requiresInput,
});
const approval = output.halted && output.items.length === 1 && output.items[0]?.type === 'approval_request'
? output.items[0]
: null;
if (approval) {
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
pipeline: remaining,
resumeAtIndex: (output.haltedAt?.index ?? -1) + 1,
items: approval.items,
prompt: approval.prompt,
});
writeToolEnvelope({
ok: true,
status: 'needs_approval',
output: [],
requiresApproval: { ...approval, resumeToken },
});
return;
}
writeToolEnvelope({ ok: true, status: 'ok', output: output.items, requiresApproval: null });
} catch (err) {
// Don't clean up index on error — allow retry by --id
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
process.exitCode = 1;
}
}
function streamFromItems(items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
})();
}
async function readVersion() {
const { readFile } = await import("node:fs/promises");
const { fileURLToPath } = await import("node:url");
const { dirname, join } = await import("node:path");
const { readFile } = await import('node:fs/promises');
const { fileURLToPath } = await import('node:url');
const { dirname, join } = await import('node:path');
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = join(here, "..", "..", "package.json");
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
return pkg.version ?? "0.0.0";
const pkgPath = join(here, '..', '..', 'package.json');
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
return pkg.version ?? '0.0.0';
}
async function handleDoctor({ argv, registry }) {
const mode = "tool";
const mode = 'tool';
const pipeline = "exec --json --shell 'echo [1]'";
const output: any = await (async () => {
try {
@ -778,7 +420,7 @@ async function handleDoctor({ argv, registry }) {
if (output?.error) {
writeToolEnvelope({
ok: false,
error: { type: "doctor_error", message: output.error?.message ?? String(output.error) },
error: { type: 'doctor_error', message: output.error?.message ?? String(output.error) },
});
process.exitCode = 1;
return;
@ -786,17 +428,14 @@ async function handleDoctor({ argv, registry }) {
writeToolEnvelope({
ok: true,
status: "ok",
output: [
{
toolMode: true,
protocolVersion: 1,
version: await readVersion(),
notes: argv.length ? argv : undefined,
},
],
status: 'ok',
output: [{
toolMode: true,
protocolVersion: 1,
version: await readVersion(),
notes: argv.length ? argv : undefined,
}],
requiresApproval: null,
requiresInput: null,
});
}
@ -806,30 +445,20 @@ function writeToolEnvelope(payload) {
...payload,
};
process.stdout.write(JSON.stringify(envelope, null, 2));
process.stdout.write("\n");
process.stdout.write('\n');
}
function helpText() {
return (
`lobster — OpenClaw-native typed shell\n\n` +
return `lobster — Clawdbot-native typed shell\n\n` +
`Usage:\n` +
` lobster '<pipeline>'\n` +
` lobster run --mode tool '<pipeline>'\n` +
` lobster run path/to/workflow.lobster\n` +
` lobster run --file path/to/workflow.lobster --args-json '{...}'\n` +
` lobster run --dry-run --file path/to/workflow.lobster\n` +
` lobster run --dry-run '<pipeline>'\n` +
` lobster graph --file path/to/workflow.lobster --format mermaid\n` +
` lobster graph --file path/to/workflow.lobster --format dot\n` +
` lobster graph --file path/to/workflow.lobster --format ascii\n` +
` lobster resume --token <token> --approve yes|no\n` +
` lobster resume --token <token> --response-json '{...}'\n` +
` lobster resume --token <token> --cancel\n` +
` lobster doctor\n` +
` lobster version\n` +
` lobster help <command>\n\n` +
`Flags:\n` +
` --dry-run Validate and print the execution plan without running anything\n\n` +
`Modes:\n` +
` - human (default): renderers can write to stdout\n` +
` - tool: prints a single JSON envelope for easy integration\n\n` +
@ -837,19 +466,5 @@ function helpText() {
` lobster 'exec --json "echo [1,2,3]" | json'\n` +
` lobster run --mode tool 'exec --json "echo [1]" | approve --prompt "ok?"'\n\n` +
`Commands:\n` +
` exec, head, json, pick, table, where, approve, ask, openclaw.invoke, llm.invoke, llm_task.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run, graph\n`
);
}
function graphHelpText() {
return (
`lobster graph — render workflow step graphs\n\n` +
`Usage:\n` +
` lobster graph --file path/to/workflow.lobster [--format mermaid|dot|ascii] [--args-json '{...}']\n` +
` lobster graph path/to/workflow.lobster [--format mermaid|dot|ascii]\n\n` +
`Flags:\n` +
` --file Workflow file path (.lobster, .yaml, .yml, .json)\n` +
` --format Output format: mermaid (default), dot, ascii\n` +
` --args-json JSON object used to resolve workflow args for labels\n`
);
` exec, head, json, pick, table, where, approve, clawd.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run\n`;
}

View File

@ -1,28 +1,26 @@
import type { CommandMeta, LobsterCommand } from "./types.js";
import type { CommandMeta, LobsterCommand } from './types.js';
function parseDescriptionFromHelp(helpText: string): string {
const firstLine = helpText.split("\n").find((l) => l.trim().length > 0) ?? "";
const firstLine = helpText.split('\n').find((l) => l.trim().length > 0) ?? '';
// Expected pattern: "name — description" but fall back to the line as-is.
return firstLine.includes("—")
? firstLine.split("—").slice(1).join("—").trim()
: firstLine.trim();
return firstLine.includes('—') ? firstLine.split('—').slice(1).join('—').trim() : firstLine.trim();
}
export const commandsListCommand: LobsterCommand = {
name: "commands.list",
name: 'commands.list',
help() {
return (
`commands.list — list available Lobster pipeline commands\n\n` +
`Usage:\n` +
` commands.list\n\n` +
`Notes:\n` +
` - Intended for agents (e.g. OpenClaw) to discover available pipeline stages dynamically.\n` +
` - Intended for agents (e.g. Clawdbot) to discover available pipeline stages dynamically.\n` +
` - Output includes name/description plus optional metadata (argsSchema/examples/sideEffects) when provided by commands.\n`
);
},
meta: {
description: "List available Lobster pipeline commands",
argsSchema: { type: "object", properties: {}, required: [] },
description: 'List available Lobster pipeline commands',
argsSchema: { type: 'object', properties: {}, required: [] },
sideEffects: [],
} satisfies CommandMeta,
async run({ input, ctx }) {
@ -34,7 +32,7 @@ export const commandsListCommand: LobsterCommand = {
const names = ctx.registry.list();
const output = names.map((name) => {
const cmd = ctx.registry.get(name) as LobsterCommand | undefined;
const help = typeof cmd?.help === "function" ? String(cmd.help()) : "";
const help = typeof cmd?.help === 'function' ? String(cmd.help()) : '';
const description = cmd?.meta?.description ?? parseDescriptionFromHelp(help);
return {

View File

@ -10,9 +10,7 @@ import { templateCommand } from "./stdlib/template.js";
import { mapCommand } from "./stdlib/map.js";
import { groupByCommand } from "./stdlib/group_by.js";
import { approveCommand } from "./stdlib/approve.js";
import { askCommand } from "./stdlib/ask.js";
import { clawdInvokeCommand, openclawInvokeCommand } from "./stdlib/openclaw_invoke.js";
import { llmInvokeCommand } from "./stdlib/llm_invoke.js";
import { clawdInvokeCommand } from "./stdlib/clawd_invoke.js";
import { llmTaskInvokeCommand } from "./stdlib/llm_task_invoke.js";
import { stateGetCommand, stateSetCommand } from "./stdlib/state.js";
import { diffLastCommand } from "./stdlib/diff_last.js";
@ -39,10 +37,7 @@ export function createDefaultRegistry() {
mapCommand,
groupByCommand,
approveCommand,
askCommand,
openclawInvokeCommand,
clawdInvokeCommand,
llmInvokeCommand,
llmTaskInvokeCommand,
stateGetCommand,
stateSetCommand,

View File

@ -1,19 +1,17 @@
import { readLineFromStream } from "../../read_line.js";
function isInteractive(stdin) {
return Boolean(stdin.isTTY);
}
export const approveCommand = {
name: "approve",
name: 'approve',
meta: {
description: "Require confirmation to continue",
description: 'Require confirmation to continue',
argsSchema: {
type: "object",
type: 'object',
properties: {
prompt: { type: "string", description: "Approval prompt text", default: "Approve?" },
emit: { type: "boolean", description: "Force emit approval request + halt" },
_: { type: "array", items: { type: "string" } },
prompt: { type: 'string', description: 'Approval prompt text', default: 'Approve?' },
emit: { type: 'boolean', description: 'Force emit approval request + halt' },
_: { type: 'array', items: { type: 'string' } },
},
required: [],
},
@ -23,15 +21,15 @@ export const approveCommand = {
return `approve — require confirmation to continue\n\nUsage:\n ... | approve --prompt "Send these emails?"\n ... | approve --emit --prompt "Send these emails?"\n ... | approve --emit --preview-from-stdin --limit 5 --prompt "Proceed?"\n\nModes:\n - Interactive (default): prompts on TTY and passes items through if approved.\n - Emit (--emit): returns an approval request object and stops the pipeline.\n\nNotes:\n - In tool mode (or non-interactive), this emits an approval request and halts.\n`;
},
async run({ input, args, ctx }) {
const prompt = args.prompt ?? "Approve?";
const previewFromStdin = Boolean(args.previewFromStdin ?? args["preview-from-stdin"]);
const previewLimitRaw = args.limit ?? args.previewLimit ?? args["preview-limit"];
const prompt = args.prompt ?? 'Approve?';
const previewFromStdin = Boolean(args.previewFromStdin ?? args['preview-from-stdin']);
const previewLimitRaw = args.limit ?? args.previewLimit ?? args['preview-limit'];
const previewLimit = Number.isFinite(Number(previewLimitRaw)) ? Number(previewLimitRaw) : 5;
const items = [];
for await (const item of input) items.push(item);
const emit = Boolean(args.emit) || ctx.mode === "tool" || !isInteractive(ctx.stdin);
const emit = Boolean(args.emit) || ctx.mode === 'tool' || !isInteractive(ctx.stdin);
if (emit) {
const preview = previewFromStdin
@ -41,7 +39,7 @@ export const approveCommand = {
halt: true,
output: (async function* () {
yield {
type: "approval_request",
type: 'approval_request',
prompt,
items,
...(preview ? { preview } : null),
@ -51,12 +49,10 @@ export const approveCommand = {
}
ctx.stdout.write(`${prompt} [y/N] `);
const answer = await readLineFromStream(ctx.stdin, {
timeoutMs: parseApprovalTimeoutMs(ctx.env),
});
const answer = await readLine(ctx.stdin);
if (!/^y(es)?$/i.test(String(answer).trim())) {
throw new Error("Not approved");
throw new Error('Not approved');
}
return { output: asStream(items) };
@ -64,18 +60,28 @@ export const approveCommand = {
};
function buildPreview(items) {
if (!items.length) return "";
if (items.every((item) => typeof item === "string")) {
return items.join("\n");
if (!items.length) return '';
if (items.every((item) => typeof item === 'string')) {
return items.join('\n');
}
return JSON.stringify(items, null, 2);
}
function parseApprovalTimeoutMs(env) {
const raw = env?.LOBSTER_APPROVAL_INPUT_TIMEOUT_MS;
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) return 0;
return Math.floor(value);
function readLine(stdin) {
return new Promise((resolve) => {
let buf = '';
const onData = (chunk) => {
buf += chunk.toString('utf8');
const idx = buf.indexOf('\n');
if (idx !== -1) {
stdin.off('data', onData);
resolve(buf.slice(0, idx));
}
};
stdin.on('data', onData);
});
}
async function* asStream(items) {

View File

@ -1,153 +0,0 @@
import { sharedAjv } from "../../validation.js";
function isInteractive(stdin) {
return Boolean(stdin.isTTY);
}
function compileAskValidator(schema) {
try {
return sharedAjv.compile(schema);
} catch {
throw new Error("ask response schema is invalid");
}
}
function validateAskResponse(validator, response) {
const ok = validator(response);
if (ok) return;
const first = validator.errors?.[0];
const pathValue = first?.instancePath || "/";
const reason = first?.message ? ` ${first.message}` : "";
throw new Error(`ask response failed schema validation at ${pathValue}:${reason}`);
}
function parseInteractiveCandidates(text) {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return [text, { decision: text }];
}
if (typeof parsed === "string") {
return [parsed, { decision: parsed }];
}
return [parsed];
}
export const askCommand = {
name: "ask",
meta: {
description: "Pause and request structured input from the user",
argsSchema: {
type: "object",
properties: {
prompt: {
type: "string",
description: "Question or instruction to show",
default: "Input required",
},
schema: { type: "string", description: "JSON Schema string for the expected response" },
"subject-from-stdin": {
type: "boolean",
description: "Use stdin content as the subject (preview text)",
},
emit: { type: "boolean", description: "Force emit mode" },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
sideEffects: [],
},
help() {
return [
"ask — pause and request structured input from the user",
"",
"Usage:",
' ... | ask --prompt "Approve, reject, or send feedback:"',
' ... | ask --prompt "Feedback?" --schema \'{"type":"object","properties":{"decision":{"type":"string"},"feedback":{"type":"string"}},"required":["decision"]}\'',
' ... | ask --subject-from-stdin --prompt "Review this draft:"',
"",
"Notes:",
" - In tool mode (or non-interactive), emits a needs_input envelope and halts.",
" - Use --schema to constrain the response shape (JSON Schema).",
" - Use --subject-from-stdin to embed the current pipeline value as preview text.",
].join("\n");
},
async run({ input, args, ctx }) {
const prompt = typeof args.prompt === "string" ? args.prompt : "Input required";
const subjectFromStdin = Boolean(args["subject-from-stdin"] ?? args.subjectFromStdin);
const schemaRaw = typeof args.schema === "string" ? args.schema : null;
const items = [];
for await (const item of input) items.push(item);
const defaultSchema = {
type: "object",
properties: {
decision: { type: "string", enum: ["approve", "reject", "redraft"] },
feedback: { type: "string", description: "Feedback for redraft" },
},
required: ["decision"],
};
let responseSchema = defaultSchema;
if (schemaRaw) {
let parsedSchema;
try {
parsedSchema = JSON.parse(schemaRaw);
} catch {
throw new Error("ask --schema must be valid JSON");
}
if (!parsedSchema || typeof parsedSchema !== "object" || Array.isArray(parsedSchema)) {
throw new Error("ask --schema must decode to a JSON schema object");
}
responseSchema = parsedSchema;
}
const responseValidator = compileAskValidator(responseSchema);
let subject;
if (subjectFromStdin && items.length > 0) {
const preview = items
.map((item) => (typeof item === "string" ? item : JSON.stringify(item)))
.join("\n")
.slice(0, 2000);
subject = { text: preview };
}
const emit = Boolean(args.emit) || ctx.mode === "tool" || !isInteractive(ctx.stdin);
if (emit) {
return {
halt: true,
output: (async function* () {
yield {
type: "input_request",
prompt,
responseSchema,
...(subject ? { subject } : null),
items,
};
})(),
};
}
ctx.stdout.write(`${prompt}\n> `);
const { readLineFromStream } = await import("../../read_line.js");
const raw = await readLineFromStream(ctx.stdin, { timeoutMs: 0 });
const text = String(raw ?? "").trim();
let lastError;
for (const candidate of parseInteractiveCandidates(text)) {
try {
validateAskResponse(responseValidator, candidate);
return {
output: (async function* () {
yield candidate;
})(),
};
} catch (err) {
lastError = err;
}
}
throw lastError ?? new Error("ask response failed schema validation");
},
};

View File

@ -0,0 +1,131 @@
export const clawdInvokeCommand = {
name: 'clawd.invoke',
meta: {
description: 'Call a local Clawdbot tool endpoint',
argsSchema: {
type: 'object',
properties: {
url: { type: 'string', description: 'Clawdbot control URL (or CLAWD_URL)' },
token: { type: 'string', description: 'Bearer token (or CLAWD_TOKEN)' },
tool: { type: 'string', description: 'Tool name (e.g. message, cron, github, etc.)' },
action: { type: 'string', description: 'Tool action' },
'args-json': { type: 'string', description: 'JSON string of tool args' },
sessionKey: { type: 'string', description: 'Optional session key attribution' },
'session-key': { type: 'string', description: 'Alias for sessionKey' },
dryRun: { type: 'boolean', description: 'Dry run' },
'dry-run': { type: 'boolean', description: 'Alias for dryRun' },
_: { type: 'array', items: { type: 'string' } },
},
required: ['tool', 'action'],
},
sideEffects: ['calls_clawd_tool'],
},
help() {
return `clawd.invoke — call a local Clawdbot tool endpoint\n\n` +
`Usage:\n` +
` clawd.invoke --tool message --action send --args-json '{"provider":"telegram","to":"...","message":"..."}'\n` +
` clawd.invoke --tool message --action send --args-json '{...}' --dry-run\n` +
` ... | clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'\n\n` +
`Config:\n` +
` - Uses CLAWD_URL env var by default (or pass --url).\n` +
` - Optional Bearer token via CLAWD_TOKEN env var (or pass --token).\n` +
` - Optional attribution via --session-key <sessionKey>.\n\n` +
`Notes:\n` +
` - This is a thin transport bridge. Lobster should not own OAuth/secrets.\n`;
},
async run({ input, args, ctx }) {
const each = Boolean(args.each);
const itemKey = String(args.itemKey ?? args['item-key'] ?? 'item');
const url = String(args.url ?? ctx.env.CLAWD_URL ?? '').trim();
if (!url) throw new Error('clawd.invoke requires --url or CLAWD_URL');
const tool = args.tool;
const action = args.action;
if (!tool || !action) throw new Error('clawd.invoke requires --tool and --action');
const token = String(args.token ?? ctx.env.CLAWD_TOKEN ?? '').trim();
let toolArgs = {};
if (args['args-json']) {
try {
toolArgs = JSON.parse(String(args['args-json']));
} catch (_err) {
throw new Error('clawd.invoke --args-json must be valid JSON');
}
}
if (each && (toolArgs === null || typeof toolArgs !== 'object' || Array.isArray(toolArgs))) {
throw new Error('clawd.invoke --each requires --args-json to be an object');
}
const endpoint = new URL('/tools/invoke', url);
const sessionKey = args.sessionKey ?? args['session-key'] ?? null;
const dryRun = args.dryRun ?? args['dry-run'] ?? null;
const invokeOnce = async (argsValue) => {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : null),
},
body: JSON.stringify({
tool: String(tool),
action: String(action),
args: argsValue,
...(sessionKey ? { sessionKey: String(sessionKey) } : null),
...(dryRun !== null ? { dryRun: Boolean(dryRun) } : null),
}),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`clawd.invoke failed (${res.status}): ${text.slice(0, 400)}`);
}
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch (_err) {
throw new Error('clawd.invoke expected JSON response');
}
// Preferred: { ok: true, result: ... }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'ok' in parsed) {
if (parsed.ok !== true) {
const msg = parsed?.error?.message ?? 'Unknown error';
throw new Error(`clawd.invoke tool error: ${msg}`);
}
const result = parsed.result;
return Array.isArray(result) ? result : [result];
}
// Compatibility: raw JSON result
return Array.isArray(parsed) ? parsed : [parsed];
};
if (!each) {
// Drain input: for now we don't stream input into clawd calls.
for await (const _item of input) {
// no-op
}
const items = await invokeOnce(toolArgs);
return { output: asStream(items) };
}
return {
output: (async function* () {
for await (const item of input) {
const argsValue = { ...toolArgs, [itemKey]: item };
const items = await invokeOnce(argsValue);
for (const outputItem of items) yield outputItem;
}
})(),
};
},
};
async function* asStream(items) {
for (const item of items) yield item;
}

View File

@ -1,6 +1,6 @@
function getByPath(obj: any, path: string): any {
if (!path) return obj;
const parts = path.split(".").filter(Boolean);
const parts = path.split('.').filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -10,17 +10,14 @@ function getByPath(obj: any, path: string): any {
}
export const dedupeCommand = {
name: "dedupe",
name: 'dedupe',
meta: {
description: "Remove duplicate items, keeping first occurrence (stable)",
description: 'Remove duplicate items, keeping first occurrence (stable)',
argsSchema: {
type: "object",
type: 'object',
properties: {
key: {
type: "string",
description: "Dot-path key used for identity (defaults to whole item)",
},
_: { type: "array", items: { type: "string" } },
key: { type: 'string', description: 'Dot-path key used for identity (defaults to whole item)' },
_: { type: 'array', items: { type: 'string' } },
},
required: [],
},
@ -37,7 +34,7 @@ export const dedupeCommand = {
);
},
async run({ input, args }: any) {
const key = typeof args.key === "string" ? args.key : undefined;
const key = typeof args.key === 'string' ? args.key : undefined;
const seen = new Set<string>();
return {

View File

@ -1,25 +1,25 @@
import { diffAndStore } from "../../state/store.js";
import { diffAndStore } from '../../state/store.js';
export const diffLastCommand = {
name: "diff.last",
name: 'diff.last',
meta: {
description: "Compare current items to last stored snapshot",
description: 'Compare current items to last stored snapshot',
argsSchema: {
type: "object",
type: 'object',
properties: {
key: { type: "string", description: "State key to diff against" },
_: { type: "array", items: { type: "string" } },
key: { type: 'string', description: 'State key to diff against' },
_: { type: 'array', items: { type: 'string' } },
},
required: ["key"],
required: ['key'],
},
sideEffects: ["writes_state"],
sideEffects: ['writes_state'],
},
help() {
return `diff.last — compare current items to last stored snapshot\n\nUsage:\n <items> | diff.last --key <stateKey>\n\nOutput:\n { changed, key, before, after }\n`;
},
async run({ input, args, ctx }) {
const key = args.key ?? args._[0];
if (!key) throw new Error("diff.last requires --key");
if (!key) throw new Error('diff.last requires --key');
const afterItems = [];
for await (const item of input) afterItems.push(item);
@ -29,7 +29,7 @@ export const diffLastCommand = {
return {
output: (async function* () {
yield { kind: "diff.last", key, changed, before, after };
yield { kind: 'diff.last', key, changed, before, after };
})(),
};
},

View File

@ -8,19 +8,17 @@ type EmailLike = {
labels?: string[];
};
type NormalizedEmail = Required<
Pick<EmailLike, "id" | "threadId" | "from" | "subject" | "date" | "snippet">
> & {
type NormalizedEmail = Required<Pick<EmailLike, 'id' | 'threadId' | 'from' | 'subject' | 'date' | 'snippet'>> & {
labels: string[];
};
function normalizeEmail(raw: any): NormalizedEmail {
const id = String(raw?.id ?? raw?.messageId ?? "").trim();
const id = String(raw?.id ?? raw?.messageId ?? '').trim();
const threadId = String(raw?.threadId ?? raw?.thread_id ?? id).trim();
const from = String(raw?.from ?? raw?.sender ?? "").trim();
const subject = String(raw?.subject ?? "").trim();
const date = String(raw?.date ?? raw?.internalDate ?? raw?.timestamp ?? "").trim();
const snippet = String(raw?.snippet ?? raw?.bodyPreview ?? "").trim();
const from = String(raw?.from ?? raw?.sender ?? '').trim();
const subject = String(raw?.subject ?? '').trim();
const date = String(raw?.date ?? raw?.internalDate ?? raw?.timestamp ?? '').trim();
const snippet = String(raw?.snippet ?? raw?.bodyPreview ?? '').trim();
const labels = Array.isArray(raw?.labels) ? raw.labels.map((x: any) => String(x)) : [];
return {
@ -37,10 +35,10 @@ function normalizeEmail(raw: any): NormalizedEmail {
function isLikelyNoReply(from: string) {
const f = from.toLowerCase();
return (
f.includes("no-reply") ||
f.includes("noreply") ||
f.includes("do-not-reply") ||
f.includes("donotreply")
f.includes('no-reply') ||
f.includes('noreply') ||
f.includes('do-not-reply') ||
f.includes('donotreply')
);
}
@ -49,16 +47,16 @@ function extractEmailAddress(from: string): string {
if (m?.[1]) return m[1].trim();
// fallback: find first email-ish token
const m2 = String(from).match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return (m2?.[0] ?? "").trim();
return (m2?.[0] ?? '').trim();
}
function ensureRe(subject: string) {
const s = String(subject ?? "").trim();
if (!s) return "Re:";
const s = String(subject ?? '').trim();
if (!s) return 'Re:';
return /^re:\s*/i.test(s) ? s : `Re: ${s}`;
}
type TriageCategory = "needs_reply" | "needs_action" | "fyi";
type TriageCategory = 'needs_reply' | 'needs_action' | 'fyi';
type TriageDecision = {
id: string;
@ -80,7 +78,7 @@ type EmailTriageReport = {
emails: NormalizedEmail[];
decisions?: TriageDecision[];
drafts?: { to: string; subject: string; body: string; emailId: string }[];
mode: "deterministic" | "llm";
mode: 'deterministic' | 'llm';
};
function buildDeterministicReport(emails: NormalizedEmail[]): EmailTriageReport {
@ -92,9 +90,9 @@ function buildDeterministicReport(emails: NormalizedEmail[]): EmailTriageReport
for (const e of emails) {
const subjLower = e.subject.toLowerCase();
const unread = e.labels.some((l) => l.toUpperCase() === "UNREAD");
const unread = e.labels.some((l) => l.toUpperCase() === 'UNREAD');
if (subjLower.includes("action required") || subjLower.includes("urgent")) {
if (subjLower.includes('action required') || subjLower.includes('urgent')) {
buckets.needsAction.push(e);
continue;
}
@ -117,7 +115,7 @@ function buildDeterministicReport(emails: NormalizedEmail[]): EmailTriageReport
fyi: buckets.fyi.map((x) => x.id),
},
emails,
mode: "deterministic",
mode: 'deterministic',
};
}
@ -147,66 +145,52 @@ function triagePrompt(emails: NormalizedEmail[]) {
}
const TRIAGE_OUTPUT_SCHEMA = {
type: "object",
type: 'object',
properties: {
decisions: {
type: "array",
type: 'array',
items: {
type: "object",
type: 'object',
properties: {
id: { type: "string" },
category: { type: "string", enum: ["needs_reply", "needs_action", "fyi"] },
rationale: { type: "string" },
id: { type: 'string' },
category: { type: 'string', enum: ['needs_reply', 'needs_action', 'fyi'] },
rationale: { type: 'string' },
reply: {
type: "object",
type: 'object',
properties: {
subject: { type: "string" },
body: { type: "string" },
subject: { type: 'string' },
body: { type: 'string' },
},
required: ["body"],
required: ['body'],
additionalProperties: false,
},
},
required: ["id", "category"],
required: ['id', 'category'],
additionalProperties: false,
},
},
},
required: ["decisions"],
required: ['decisions'],
additionalProperties: false,
};
export const emailTriageCommand = {
name: "email.triage",
name: 'email.triage',
meta: {
description: "Email triage (deterministic by default, optionally LLM-assisted via llm.invoke)",
description: 'Email triage (deterministic by default, optionally LLM-assisted via llm_task.invoke)',
argsSchema: {
type: "object",
type: 'object',
properties: {
limit: {
type: "number",
description: "Maximum items to consume from input stream",
default: 20,
},
llm: { type: "boolean", description: "Use llm.invoke for categorization + draft replies" },
model: {
type: "string",
description: "Model for llm.invoke (optional; adapter defaults may apply)",
},
url: {
type: "string",
description: "Reserved for compatibility (ignored in OpenClaw mode)",
},
token: { type: "string", description: "Bearer token (or OPENCLAW_TOKEN/CLAWD_TOKEN)" },
temperature: { type: "number", description: "LLM temperature" },
"max-output-tokens": { type: "number", description: "Max completion tokens" },
emit: {
type: "string",
description: "Output mode: 'report' (default) or 'drafts'",
default: "report",
},
"state-key": { type: "string", description: "Run-state key forwarded to llm.invoke" },
_: { type: "array", items: { type: "string" } },
limit: { type: 'number', description: 'Maximum items to consume from input stream', default: 20 },
llm: { type: 'boolean', description: 'Use llm_task.invoke for categorization + draft replies (requires LLM_TASK_URL)' },
model: { type: 'string', description: 'Model for llm_task.invoke (required when --llm true)' },
url: { type: 'string', description: 'llm-task base URL (or LLM_TASK_URL)' },
token: { type: 'string', description: 'Bearer token (or LLM_TASK_TOKEN)' },
temperature: { type: 'number', description: 'LLM temperature' },
'max-output-tokens': { type: 'number', description: 'Max completion tokens' },
emit: { type: 'string', description: "Output mode: 'report' (default) or 'drafts'", default: 'report' },
'state-key': { type: 'string', description: 'Run-state key forwarded to llm_task.invoke' },
_: { type: 'array', items: { type: 'string' } },
},
required: [],
},
@ -223,12 +207,12 @@ export const emailTriageCommand = {
` ... | email.triage --llm --model <model> --emit drafts | approve --prompt 'Send replies?' | gog.gmail.send\n\n` +
`Notes:\n` +
` - Read-only by default: does not send anything.\n` +
` - LLM mode uses llm.invoke (and its cache/resume semantics).\n`
` - LLM mode uses llm_task.invoke (and its cache/resume semantics).\n`
);
},
async run({ input, args, ctx }) {
const limit = Number(args.limit ?? 20);
const emit = String(args.emit ?? "report").trim() || "report";
const emit = String(args.emit ?? 'report').trim() || 'report';
const emails: NormalizedEmail[] = [];
for await (const item of input) {
@ -238,26 +222,22 @@ export const emailTriageCommand = {
const wantLlm = Boolean(args.llm ?? false);
const env = ctx?.env ?? process.env;
const hasLlmProvider = Boolean(
String(env.LOBSTER_LLM_PROVIDER ?? "").trim() ||
String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? "").trim() ||
String(env.LOBSTER_LLM_ADAPTER_URL ?? "").trim() ||
String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? "").trim(),
);
const hasClawdUrl = Boolean(String(env.CLAWD_URL ?? '').trim());
if (!wantLlm || !hasLlmProvider) {
if (!wantLlm || !hasClawdUrl) {
const report = buildDeterministicReport(emails);
if (emit === "drafts") {
if (emit === 'drafts') {
return { output: streamOf([]) };
}
return { output: streamOf([report]) };
}
const model = String(args.model ?? "").trim();
const model = String(args.model ?? '').trim();
// Model is optional when running under Clawdbot (llm_task.invoke will use Clawdbot defaults).
if (!ctx?.registry) throw new Error("email.triage (LLM mode) requires ctx.registry");
const llmCmd = ctx.registry.get("llm.invoke") ?? ctx.registry.get("llm_task.invoke");
if (!llmCmd) throw new Error("email.triage requires llm.invoke to be registered");
if (!ctx?.registry) throw new Error('email.triage (LLM mode) requires ctx.registry');
const llmCmd = ctx.registry.get('llm_task.invoke');
if (!llmCmd) throw new Error('email.triage requires llm_task.invoke to be registered');
const llmRes = await llmCmd.run({
input: streamOf(emails),
@ -267,11 +247,11 @@ export const emailTriageCommand = {
token: args.token,
...(model ? { model } : null),
prompt: triagePrompt(emails),
"output-schema": JSON.stringify(TRIAGE_OUTPUT_SCHEMA),
"schema-version": "email_triage.v1",
'output-schema': JSON.stringify(TRIAGE_OUTPUT_SCHEMA),
'schema-version': 'email_triage.v1',
temperature: args.temperature,
"max-output-tokens": args["max-output-tokens"],
"state-key": args["state-key"] ?? env.LOBSTER_RUN_STATE_KEY,
'max-output-tokens': args['max-output-tokens'],
'state-key': args['state-key'] ?? env.LOBSTER_RUN_STATE_KEY,
},
ctx,
} as any);
@ -281,17 +261,12 @@ export const emailTriageCommand = {
const first = llmItems[0];
const data = first?.output?.data;
const decisionsRaw = Array.isArray(data?.decisions) ? data.decisions : [];
const decisions: TriageDecision[] = decisionsRaw
.map((d: any) => ({
id: String(d?.id ?? "").trim(),
category: String(d?.category ?? "fyi") as TriageCategory,
rationale: d?.rationale ? String(d.rationale) : undefined,
reply:
d?.reply && typeof d.reply === "object"
? { subject: d.reply.subject, body: String(d.reply.body ?? "") }
: undefined,
}))
.filter((d: any) => d.id);
const decisions: TriageDecision[] = decisionsRaw.map((d: any) => ({
id: String(d?.id ?? '').trim(),
category: String(d?.category ?? 'fyi') as TriageCategory,
rationale: d?.rationale ? String(d.rationale) : undefined,
reply: d?.reply && typeof d.reply === 'object' ? { subject: d.reply.subject, body: String(d.reply.body ?? '') } : undefined,
})).filter((d: any) => d.id);
const byId = new Map(emails.map((e) => [e.id, e] as const));
const buckets = {
@ -303,18 +278,18 @@ export const emailTriageCommand = {
const drafts: { to: string; subject: string; body: string; emailId: string }[] = [];
for (const d of decisions) {
if (d.category === "needs_reply") buckets.needsReply.push(d.id);
else if (d.category === "needs_action") buckets.needsAction.push(d.id);
if (d.category === 'needs_reply') buckets.needsReply.push(d.id);
else if (d.category === 'needs_action') buckets.needsAction.push(d.id);
else buckets.fyi.push(d.id);
if (d.category === "needs_reply" && d.reply?.body) {
if (d.category === 'needs_reply' && d.reply?.body) {
const email = byId.get(d.id);
const to = email ? extractEmailAddress(email.from) : "";
if (to && !isLikelyNoReply(email?.from ?? "")) {
const to = email ? extractEmailAddress(email.from) : '';
if (to && !isLikelyNoReply(email?.from ?? '')) {
drafts.push({
emailId: d.id,
to,
subject: d.reply.subject ? String(d.reply.subject) : ensureRe(email?.subject ?? ""),
subject: d.reply.subject ? String(d.reply.subject) : ensureRe(email?.subject ?? ''),
body: String(d.reply.body),
});
}
@ -323,7 +298,7 @@ export const emailTriageCommand = {
const summary = `${buckets.needsReply.length} need replies, ${buckets.needsAction.length} need action, ${buckets.fyi.length} FYI`;
if (emit === "drafts") {
if (emit === 'drafts') {
return {
output: (async function* () {
for (const d of drafts) {
@ -340,7 +315,7 @@ export const emailTriageCommand = {
emails,
decisions,
drafts,
mode: "llm",
mode: 'llm',
};
return { output: streamOf([report]) };

View File

@ -1,24 +1,22 @@
import { spawn } from "node:child_process";
import { resolveInlineShellCommand } from "../../shell.js";
import { spawn } from 'node:child_process';
export const execCommand = {
name: "exec",
name: 'exec',
meta: {
description: "Run an OS command",
description: 'Run an OS command',
argsSchema: {
type: "object",
type: 'object',
properties: {
json: { type: "boolean", description: "Parse stdout as JSON (single value)." },
shell: { type: "string", description: "Run via the system shell with this command line." },
_: { type: "array", items: { type: "string" }, description: "Command + args." },
json: { type: 'boolean', description: 'Parse stdout as JSON (single value).' },
shell: { type: 'string', description: 'Run via /bin/sh -lc with this command line.' },
_: { type: 'array', items: { type: 'string' }, description: 'Command + args.' },
},
required: ["_"],
required: ['_'],
},
sideEffects: ["local_exec"],
sideEffects: ['local_exec'],
},
help() {
return (
`exec — run an OS command\n\n` +
return `exec — run an OS command\n\n` +
`Usage:\n` +
` exec <command...>\n` +
` exec --stdin raw|json|jsonl <command...>\n` +
@ -27,18 +25,16 @@ export const execCommand = {
`Notes:\n` +
` - With --json, parses stdout as JSON (single value).\n` +
` - With --stdin, writes pipeline input to stdin.\n` +
` - With --shell (or a single arg containing spaces), runs via the system shell.\n`
);
` - With --shell (or a single arg containing spaces), runs via /bin/sh -lc.\n`;
},
async run({ input, args, ctx }) {
const cmd = args._;
const cwd = ctx?.cwd ?? process.cwd();
const shellLine = typeof args.shell === "string" ? args.shell : null;
const shellLine = typeof args.shell === 'string' ? args.shell : null;
const useShell = Boolean(args.shell) || (cmd.length === 1 && /\s/.test(cmd[0]));
const stdinMode = typeof args.stdin === "string" ? String(args.stdin).toLowerCase() : null;
const stdinMode = typeof args.stdin === 'string' ? String(args.stdin).toLowerCase() : null;
if (!cmd.length && !shellLine) throw new Error("exec requires a command");
if (!cmd.length && !shellLine) throw new Error('exec requires a command');
let stdinPayload = null;
if (stdinMode) {
@ -53,27 +49,15 @@ export const execCommand = {
}
const result = useShell
? await runShellLine(shellLine ?? cmd[0] ?? "", {
env: ctx.env,
cwd,
stdin: stdinPayload,
signal: ctx.signal,
})
: await runProcess(cmd[0], cmd.slice(1), {
env: ctx.env,
cwd,
stdin: stdinPayload,
signal: ctx.signal,
});
? await runProcess('/bin/sh', ['-lc', shellLine ?? cmd[0] ?? ''], { env: ctx.env, cwd: process.cwd(), stdin: stdinPayload })
: await runProcess(cmd[0], cmd.slice(1), { env: ctx.env, cwd: process.cwd(), stdin: stdinPayload });
if (args.json) {
let parsed;
try {
parsed = JSON.parse(result.stdout.trim() || "null");
parsed = JSON.parse(result.stdout.trim() || 'null');
} catch (err) {
throw new Error(
`exec --json could not parse stdout as JSON: ${err?.message ?? String(err)}`,
);
throw new Error(`exec --json could not parse stdout as JSON: ${err?.message ?? String(err)}`);
}
return {
@ -86,54 +70,44 @@ export const execCommand = {
},
};
function runProcess(command, argv, { env, cwd, stdin, signal }) {
function runProcess(command, argv, { env, cwd, stdin }) {
return new Promise<any>((resolve, reject) => {
const child = spawn(command, argv, {
env,
cwd,
signal,
stdio: ["pipe", "pipe", "pipe"],
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = "";
let stderr = "";
let stdout = '';
let stderr = '';
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on("data", (d) => {
stdout += d;
});
child.stderr.on("data", (d) => {
stderr += d;
});
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
if (typeof stdin === "string") {
child.stdin.setDefaultEncoding("utf8");
if (typeof stdin === 'string') {
child.stdin.setDefaultEncoding('utf8');
child.stdin.write(stdin);
}
child.stdin.end();
child.on("error", reject);
child.on("close", (code) => {
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) return resolve({ stdout, stderr });
reject(new Error(`exec failed (${code}): ${stderr.trim() || stdout.trim() || command}`));
});
});
}
function runShellLine(commandLine, { env, cwd, stdin, signal }) {
const shell = resolveInlineShellCommand({ command: commandLine, env });
return runProcess(shell.command, shell.argv, { env, cwd, stdin, signal });
}
function encodeStdin(items, mode) {
if (mode === "json") return JSON.stringify(items);
if (mode === "jsonl") {
return items.map((item) => JSON.stringify(item)).join("\n") + (items.length ? "\n" : "");
if (mode === 'json') return JSON.stringify(items);
if (mode === 'jsonl') {
return items.map((item) => JSON.stringify(item)).join('\n') + (items.length ? '\n' : '');
}
if (mode === "raw") {
return items.map((item) => (typeof item === "string" ? item : JSON.stringify(item))).join("\n");
if (mode === 'raw') {
return items.map((item) => (typeof item === 'string' ? item : JSON.stringify(item))).join('\n');
}
throw new Error(`exec --stdin must be raw, json, or jsonl (got ${mode})`);
}

View File

@ -15,7 +15,7 @@ function run(cmd: string, argv: string[], env: Record<string, string | undefined
child.on("error", (err: any) => {
if (err?.code === "ENOENT") {
reject(new Error("gog not found on PATH (install: https://github.com/steipete/gogcli)"));
reject(new Error("gog not found on PATH (install: https://github.com/steipete/gog)"));
return;
}
reject(err);
@ -41,7 +41,7 @@ export const gogGmailSearchCommand = {
},
required: [],
},
sideEffects: ["reads_email"],
sideEffects: ['reads_email'],
},
help() {
return (
@ -49,7 +49,7 @@ export const gogGmailSearchCommand = {
`Usage:\n` +
` gog.gmail.search --query 'newer_than:1d' --max 20\n\n` +
`Notes:\n` +
` - Requires the gog CLI: https://github.com/steipete/gogcli\n` +
` - Requires the gog CLI: https://github.com/steipete/gog\n` +
` - Set GOG_BIN to override the executable used (default: gog).\n` +
` - This command outputs an array of message objects (as a stream).\n`
);

View File

@ -15,7 +15,7 @@ function run(cmd: string, argv: string[], env: Record<string, string | undefined
child.on("error", (err: any) => {
if (err?.code === "ENOENT") {
reject(new Error("gog not found on PATH (install: https://github.com/steipete/gogcli)"));
reject(new Error("gog not found on PATH (install: https://github.com/steipete/gog)"));
return;
}
reject(err);
@ -57,7 +57,7 @@ export const gogGmailSendCommand = {
},
required: [],
},
sideEffects: ["sends_email"],
sideEffects: ['sends_email'],
},
help() {
return (
@ -67,7 +67,7 @@ export const gogGmailSendCommand = {
`Input:\n` +
` Stream of draft objects: { to, subject, body }\n\n` +
`Notes:\n` +
` - Requires the gog CLI: https://github.com/steipete/gogcli\n` +
` - Requires the gog CLI: https://github.com/steipete/gog\n` +
` - Set GOG_BIN to override the executable used (default: gog).\n`
);
},

View File

@ -1,5 +1,5 @@
function getByPath(obj: any, path: string): any {
const parts = path.split(".").filter(Boolean);
const parts = path.split('.').filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -9,16 +9,16 @@ function getByPath(obj: any, path: string): any {
}
export const groupByCommand = {
name: "groupBy",
name: 'groupBy',
meta: {
description: "Group items by a key (stable group order)",
description: 'Group items by a key (stable group order)',
argsSchema: {
type: "object",
type: 'object',
properties: {
key: { type: "string", description: "Dot-path key to group by (required)" },
_: { type: "array", items: { type: "string" } },
key: { type: 'string', description: 'Dot-path key to group by (required)' },
_: { type: 'array', items: { type: 'string' } },
},
required: ["key"],
required: ['key'],
},
sideEffects: [],
},
@ -34,8 +34,8 @@ export const groupByCommand = {
);
},
async run({ input, args }: any) {
const keyPath = String(args.key ?? "").trim();
if (!keyPath) throw new Error("groupBy requires --key");
const keyPath = String(args.key ?? '').trim();
if (!keyPath) throw new Error('groupBy requires --key');
const groups = new Map<string, { key: any; items: any[] }>();
const order: string[] = [];

View File

@ -1,12 +1,12 @@
export const headCommand = {
name: "head",
name: 'head',
meta: {
description: "Take first N items",
description: 'Take first N items',
argsSchema: {
type: "object",
type: 'object',
properties: {
n: { type: "number", description: "Number of items to take", default: 10 },
_: { type: "array", items: { type: "string" } },
n: { type: 'number', description: 'Number of items to take', default: 10 },
_: { type: 'array', items: { type: 'string' } },
},
required: [],
},
@ -17,7 +17,7 @@ export const headCommand = {
},
async run({ input, args }) {
const n = args.n === undefined ? 10 : Number(args.n);
if (!Number.isFinite(n) || n < 0) throw new Error("head --n must be a non-negative number");
if (!Number.isFinite(n) || n < 0) throw new Error('head --n must be a non-negative number');
return {
output: (async function* () {

View File

@ -1,8 +1,8 @@
export const jsonCommand = {
name: "json",
name: 'json',
meta: {
description: "Render pipeline output as JSON",
argsSchema: { type: "object", properties: {}, required: [] },
description: 'Render pipeline output as JSON',
argsSchema: { type: 'object', properties: {}, required: [] },
sideEffects: [],
},
help() {

View File

@ -1,938 +0,0 @@
import path from "node:path";
import { promises as fsp } from "node:fs";
import { createHash } from "node:crypto";
import { Ajv } from "ajv";
import type { ErrorObject } from "ajv";
import { readStateJson, writeStateJson, stableStringify } from "../../state/store.js";
import type { LobsterCommand } from "../types.js";
const ajv = new Ajv({ allErrors: true, strict: false });
const artifactSchema = {
type: "object",
properties: {
kind: { type: "string" },
role: { type: "string" },
name: { type: "string" },
mimeType: { type: "string" },
text: { type: "string" },
data: {},
uri: { type: "string" },
},
additionalProperties: true,
};
const payloadSchema = {
type: "object",
properties: {
prompt: { type: "string", minLength: 1 },
model: { type: "string", minLength: 1 },
artifacts: { type: "array", items: artifactSchema },
artifactHashes: { type: "array", items: { type: "string", minLength: 10 } },
schemaVersion: { type: "string" },
metadata: { type: "object", additionalProperties: true },
outputSchema: { type: "object", additionalProperties: true },
temperature: { type: "number" },
maxOutputTokens: { type: "number" },
retryContext: {
type: "object",
properties: {
attempt: { type: "number" },
validationErrors: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
},
required: ["prompt", "artifacts", "artifactHashes"],
additionalProperties: false,
};
const responseSchema = {
type: "object",
properties: {
ok: { type: "boolean" },
result: {
type: "object",
properties: {
runId: { type: "string" },
model: { type: "string" },
prompt: { type: "string" },
status: { type: "string" },
output: {
type: "object",
properties: {
text: { type: "string" },
data: {},
format: { type: "string" },
},
required: [],
additionalProperties: true,
},
usage: {
type: "object",
properties: {
inputTokens: { type: "number" },
outputTokens: { type: "number" },
totalTokens: { type: "number" },
},
additionalProperties: true,
},
warnings: { type: "array", items: { type: "string" } },
metadata: { type: "object", additionalProperties: true },
diagnostics: { type: "object", additionalProperties: true },
},
required: ["output"],
additionalProperties: true,
},
error: { type: "object", additionalProperties: true },
},
required: ["ok"],
additionalProperties: true,
};
const validatePayload = ajv.compile(payloadSchema);
const validateResponseEnvelope = ajv.compile(responseSchema);
const DEFAULT_MAX_VALIDATION_RETRIES = 1;
const STATE_VERSION = 1;
type BuiltInProvider = "openclaw" | "pi" | "http";
type SupportedProvider = BuiltInProvider | string;
type LlmResponseEnvelope = {
ok: boolean;
result?: LlmResponse | null;
error?: { message?: string } | null;
};
type LlmResponse = {
runId?: string | null;
model?: string | null;
prompt?: string | null;
status?: string | null;
output?: {
text?: string | null;
data?: any;
format?: string | null;
} | null;
usage?: Record<string, unknown> | null;
warnings?: string[] | null;
metadata?: Record<string, unknown> | null;
diagnostics?: Record<string, unknown> | null;
};
type NormalizedInvocationItem = {
kind: string;
runId: string | null;
prompt: string | null;
model: string | null;
schemaVersion: string | null;
status: string;
cacheKey: string;
artifactHashes: string[];
output: { format: string | null; text: string | null; data: any };
usage: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
warnings: string[] | null;
diagnostics: Record<string, unknown> | null;
createdAt: string;
source: string;
cached: boolean;
attemptCount: number;
};
type CacheEntry = {
items: NormalizedInvocationItem[];
cacheKey: string;
storedAt: string;
};
type CommandConfig = {
name: string;
itemKind: string;
stateType: string;
cacheNamespace: string;
defaultProvider?: SupportedProvider | null;
description: string;
helpTitle: string;
helpConfig: string[];
helpExamples: string[];
sourceForProvider?: (provider: SupportedProvider) => string;
legacyEnvCompat?: boolean;
};
type Adapter = {
provider: SupportedProvider;
source: string;
invoke: (params: {
env: any;
args: any;
payload: Record<string, any>;
}) => Promise<LlmResponseEnvelope>;
};
type DirectAdapter =
| ((params: {
env: any;
args: any;
payload: Record<string, any>;
ctx: any;
}) => Promise<LlmResponseEnvelope>)
| {
source?: string;
invoke: (params: {
env: any;
args: any;
payload: Record<string, any>;
ctx: any;
}) => Promise<LlmResponseEnvelope>;
};
export const llmInvokeCommand = createLlmInvokeCommand({
name: "llm.invoke",
itemKind: "llm.invoke",
stateType: "llm.invoke",
cacheNamespace: "llm.invoke",
defaultProvider: null,
description: "Call a configured LLM adapter with typed payloads and caching",
helpTitle: "llm.invoke — call a configured LLM adapter with caching and schema validation",
helpConfig: [
"Provider resolution order: --provider, LOBSTER_LLM_PROVIDER, then environment auto-detect.",
"Built-in providers: openclaw, pi, http.",
"OpenClaw provider uses OPENCLAW_URL (CLAWD_URL also supported) and OPENCLAW_TOKEN.",
"Pi provider uses LOBSTER_PI_LLM_ADAPTER_URL and is intended to be supplied by a Pi extension.",
"Generic http provider uses LOBSTER_LLM_ADAPTER_URL and optional LOBSTER_LLM_ADAPTER_TOKEN.",
],
helpExamples: [
"llm.invoke --prompt 'Write summary'",
"llm.invoke --provider openclaw --model claude-3-sonnet --prompt 'Write summary'",
"cat artifacts.json | llm.invoke --provider pi --prompt 'Score each item'",
"... | llm.invoke --prompt 'Plan next steps' --output-schema '{\"type\":\"object\"}'",
],
sourceForProvider(provider) {
return provider;
},
legacyEnvCompat: true,
});
export const llmTaskInvokeCommand = createLlmInvokeCommand({
name: "llm_task.invoke",
itemKind: "llm_task.invoke",
stateType: "llm_task.invoke",
cacheNamespace: "llm_task.invoke",
defaultProvider: "openclaw",
description: "Backward-compatible alias for llm.invoke using the OpenClaw adapter",
helpTitle: "llm_task.invoke — backward-compatible alias for llm.invoke using OpenClaw",
helpConfig: [
"Requires OPENCLAW_URL (or CLAWD_URL) and optionally OPENCLAW_TOKEN.",
"Use llm.invoke for new workflows and non-OpenClaw adapters.",
],
helpExamples: [
"llm_task.invoke --prompt 'Write summary'",
"llm_task.invoke --model claude-3-sonnet --prompt 'Write summary'",
"cat artifacts.json | llm_task.invoke --prompt 'Score each item'",
],
sourceForProvider() {
return "clawd";
},
legacyEnvCompat: true,
});
export function createLlmInvokeCommand(config: CommandConfig): LobsterCommand {
return {
name: config.name,
meta: {
description: config.description,
argsSchema: {
type: "object",
properties: {
provider: {
type: "string",
description: "LLM adapter provider (openclaw, pi, http). Optional if auto-detected.",
},
token: {
type: "string",
description: "Optional bearer token for providers that support it.",
},
prompt: { type: "string", description: "Primary prompt / instructions" },
model: {
type: "string",
description: "Model identifier. Optional; adapter defaults may apply if omitted.",
},
"artifacts-json": { type: "string", description: "JSON array of artifacts to send" },
"metadata-json": { type: "string", description: "JSON object of metadata to include" },
"output-schema": { type: "string", description: "JSON schema LLM output must satisfy" },
"schema-version": { type: "string", description: "Logical schema version for caching" },
"max-validation-retries": {
type: "number",
description: "Retries when schema validation fails",
},
temperature: { type: "number", description: "Sampling temperature" },
"max-output-tokens": { type: "number", description: "Max completion tokens" },
"state-key": {
type: "string",
description: "Run-state key override (else LOBSTER_RUN_STATE_KEY)",
},
refresh: { type: "boolean", description: "Bypass run-state + cache" },
"disable-cache": { type: "boolean", description: "Skip persistent cache" },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
sideEffects: ["calls_llm"],
},
help() {
const lines = [
config.helpTitle,
"",
"Usage:",
...config.helpExamples.map((example) => ` ${example}`),
"",
"Features:",
" - Typed payload validation before invoking the adapter.",
" - Run-state + file cache so resumes do not re-call the LLM.",
" - Optional JSON-schema enforcement with bounded retries.",
"",
"Config:",
...config.helpConfig.map((line) => ` - ${line}`),
];
return `${lines.join("\n")}\n`;
},
async run({ input, args, ctx }) {
return runLlmInvoke({ input, args, ctx, config });
},
} satisfies LobsterCommand;
}
async function runLlmInvoke({
input,
args,
ctx,
config,
}: {
input: AsyncIterable<any>;
args: any;
ctx: any;
config: CommandConfig;
}) {
const env = ctx.env ?? process.env;
const provider = resolveProvider(args, env, config.defaultProvider, ctx);
const adapter = resolveAdapter({ provider, env, args, config, ctx });
const prompt = extractPrompt(args);
if (!prompt) throw new Error(`${config.name} requires --prompt or positional text`);
const model = resolveModel(args, env, config.legacyEnvCompat);
const schemaVersion = resolveEnvString(
args["schema-version"],
["LOBSTER_LLM_SCHEMA_VERSION", ...(config.legacyEnvCompat ? ["LLM_TASK_SCHEMA_VERSION"] : [])],
env,
"v1",
);
const maxOutputTokens = parseOptionalNumber(args["max-output-tokens"]);
const temperature = parseOptionalNumber(args.temperature);
const providedArtifacts = parseJsonArray(
args["artifacts-json"],
`${config.name} --artifacts-json`,
);
const metadataObject = parseJsonObject(args["metadata-json"], `${config.name} --metadata-json`);
const userOutputSchema = parseJsonObject(args["output-schema"], `${config.name} --output-schema`);
const maxValidationRetriesRaw =
args["max-validation-retries"] ??
getFirstEnv(env, [
"LOBSTER_LLM_VALIDATION_RETRIES",
...(config.legacyEnvCompat ? ["LLM_TASK_VALIDATION_RETRIES"] : []),
]);
const maxValidationRetries = userOutputSchema
? Math.max(
0,
Number.isFinite(Number(maxValidationRetriesRaw))
? Number(maxValidationRetriesRaw)
: DEFAULT_MAX_VALIDATION_RETRIES,
)
: 0;
const disableCache = flag(args["disable-cache"]);
const forceRefresh = flag(
args.refresh ??
getFirstEnv(env, [
"LOBSTER_LLM_FORCE_REFRESH",
...(config.legacyEnvCompat ? ["LLM_TASK_FORCE_REFRESH"] : []),
]),
);
const stateKey = String(args["state-key"] ?? env.LOBSTER_RUN_STATE_KEY ?? "").trim() || null;
const inputArtifacts: any[] = [];
for await (const item of input) inputArtifacts.push(item);
const normalizedArtifacts = [...inputArtifacts, ...providedArtifacts].map(normalizeArtifact);
const artifactHashes = normalizedArtifacts.map(hashArtifact);
const cacheKey = computeCacheKey({
provider,
prompt,
model,
schemaVersion,
artifactHashes,
outputSchema: userOutputSchema,
});
if (stateKey && !forceRefresh) {
const stored = await readStateJson({ env, key: stateKey }).catch(() => null);
const reused = pickReusableState(stored, cacheKey, config.stateType);
if (reused) {
return {
output: streamOf(
reused.items.map((item) => ({ ...item, source: "run_state", cached: true })),
),
};
}
}
if (!disableCache && !forceRefresh) {
const cache = await readCacheEntry(env, cacheKey, config.cacheNamespace);
if (cache) {
return {
output: streamOf(cache.items.map((item) => ({ ...item, source: "cache", cached: true }))),
};
}
}
const payload: Record<string, any> = {
prompt,
...(model ? { model } : null),
artifacts: normalizedArtifacts,
artifactHashes,
};
if (metadataObject) payload.metadata = metadataObject;
if (userOutputSchema) payload.outputSchema = userOutputSchema;
if (schemaVersion) payload.schemaVersion = schemaVersion;
if (Number.isFinite(maxOutputTokens ?? NaN)) payload.maxOutputTokens = Number(maxOutputTokens);
if (Number.isFinite(temperature ?? NaN)) payload.temperature = Number(temperature);
if (!validatePayload(payload)) {
throw new Error(`${config.name} payload invalid: ${ajv.errorsText(validatePayload.errors)}`);
}
const validator = userOutputSchema ? ajv.compile(userOutputSchema) : null;
let attempt = 0;
let lastValidationErrors: string[] = [];
while (true) {
attempt += 1;
if (attempt > 1) {
payload.retryContext = {
attempt,
...(lastValidationErrors.length ? { validationErrors: lastValidationErrors } : null),
};
} else {
delete payload.retryContext;
}
let responseEnvelope: LlmResponseEnvelope;
try {
responseEnvelope = await adapter.invoke({ env, args, payload });
} catch (err: any) {
throw new Error(`${config.name} request failed: ${err?.message ?? String(err)}`);
}
if (!validateResponseEnvelope(responseEnvelope)) {
throw new Error(`${config.name} received invalid response envelope`);
}
if (responseEnvelope.ok !== true) {
const message = responseEnvelope.error?.message ?? "llm adapter returned an error";
throw new Error(`${config.name} remote error: ${message}`);
}
const normalized = normalizeResult({
envelope: responseEnvelope,
cacheKey,
schemaVersion,
artifactHashes,
source: adapter.source,
attempt,
itemKind: config.itemKind,
});
if (!validator) {
await persistOutputs({
env,
stateKey,
cacheKey,
items: normalized,
stateType: config.stateType,
});
if (!disableCache) await writeCacheEntry(env, cacheKey, normalized, config.cacheNamespace);
return { output: streamOf(normalized) };
}
const structured = normalized[0]?.output?.data ?? null;
if (validator(structured)) {
await persistOutputs({
env,
stateKey,
cacheKey,
items: normalized,
stateType: config.stateType,
});
if (!disableCache) await writeCacheEntry(env, cacheKey, normalized, config.cacheNamespace);
return { output: streamOf(normalized) };
}
lastValidationErrors = collectAjvErrors(validator.errors);
if (attempt > maxValidationRetries + 1) {
throw new Error(
`${config.name} output failed schema validation: ${lastValidationErrors.join("; ")}`,
);
}
}
}
function resolveProvider(
args: any,
env: any,
defaultProvider?: SupportedProvider | null,
ctx?: any,
): SupportedProvider {
const explicit = String(args.provider ?? env.LOBSTER_LLM_PROVIDER ?? "")
.trim()
.toLowerCase();
if (explicit) {
if (explicit === "openclaw" || explicit === "pi" || explicit === "http") {
return explicit;
}
if (getDirectAdapter(ctx, explicit)) {
return explicit;
}
throw new Error(`Unsupported llm provider: ${explicit}`);
}
if (defaultProvider) return defaultProvider;
const directAdapters =
ctx?.llmAdapters && typeof ctx.llmAdapters === "object"
? Object.keys(ctx.llmAdapters).filter((key) => getDirectAdapter(ctx, key))
: [];
if (directAdapters.length === 1) return directAdapters[0];
if (String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? "").trim()) return "pi";
if (String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? "").trim()) return "openclaw";
if (String(env.LOBSTER_LLM_ADAPTER_URL ?? "").trim()) return "http";
throw new Error(
"llm.invoke could not resolve a provider. Set --provider or LOBSTER_LLM_PROVIDER",
);
}
function resolveAdapter({
provider,
env,
args,
config,
ctx,
}: {
provider: SupportedProvider;
env: any;
args: any;
config: CommandConfig;
ctx: any;
}): Adapter {
const direct = getDirectAdapter(ctx, provider);
if (direct) {
const invoke = typeof direct === "function" ? direct : direct.invoke;
return {
provider,
source: typeof direct === "function" ? provider : (direct.source ?? provider),
async invoke({ payload }) {
return invoke({ env, args, payload, ctx });
},
};
}
if (provider === "openclaw") {
const openclawUrl = String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? "").trim();
if (!openclawUrl) {
throw new Error(`${config.name} requires OPENCLAW_URL (or CLAWD_URL) for provider=openclaw`);
}
const endpoint = new URL("/tools/invoke", openclawUrl);
const token = String(args.token ?? env.OPENCLAW_TOKEN ?? env.CLAWD_TOKEN ?? "").trim();
return {
provider,
source: config.sourceForProvider?.(provider) ?? "openclaw",
async invoke({ payload }) {
return invokeOpenClawAdapter({ endpoint, token, payload });
},
};
}
if (provider === "pi") {
const adapterUrl = String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? "").trim();
if (!adapterUrl) {
throw new Error(`${config.name} requires LOBSTER_PI_LLM_ADAPTER_URL for provider=pi`);
}
const token = String(args.token ?? env.LOBSTER_PI_LLM_ADAPTER_TOKEN ?? "").trim();
return {
provider,
source: config.sourceForProvider?.(provider) ?? "pi",
async invoke({ payload }) {
return invokeHttpAdapter({ endpoint: buildAdapterEndpoint(adapterUrl), token, payload });
},
};
}
const adapterUrl = String(env.LOBSTER_LLM_ADAPTER_URL ?? "").trim();
if (!adapterUrl) {
throw new Error(`${config.name} requires LOBSTER_LLM_ADAPTER_URL for provider=http`);
}
const token = String(args.token ?? env.LOBSTER_LLM_ADAPTER_TOKEN ?? "").trim();
return {
provider,
source: config.sourceForProvider?.(provider) ?? "http",
async invoke({ payload }) {
return invokeHttpAdapter({ endpoint: buildAdapterEndpoint(adapterUrl), token, payload });
},
};
}
function getDirectAdapter(ctx: any, provider: string): DirectAdapter | null {
const adapters = ctx?.llmAdapters;
if (!adapters || typeof adapters !== "object") return null;
const adapter = adapters[provider];
if (typeof adapter === "function") return adapter as DirectAdapter;
if (adapter && typeof adapter === "object" && typeof adapter.invoke === "function") {
return adapter as DirectAdapter;
}
return null;
}
function buildAdapterEndpoint(rawUrl: string) {
const endpoint = new URL(rawUrl);
if (endpoint.pathname === "/" || endpoint.pathname === "") {
endpoint.pathname = "/invoke";
}
return endpoint;
}
async function invokeOpenClawAdapter({
endpoint,
token,
payload,
}: {
endpoint: URL;
token: string;
payload: any;
}) {
const res = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : null),
},
body: JSON.stringify({
tool: "llm-task",
action: "invoke",
args: payload,
}),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 400)}`);
}
let parsed: any;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
throw new Error("Response was not JSON");
}
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
if (parsed.ok !== true) {
const msg = parsed?.error?.message ?? "Unknown error";
throw new Error(`openclaw adapter error: ${msg}`);
}
const inner = parsed.result;
if (inner && typeof inner === "object" && !Array.isArray(inner) && "ok" in inner) {
return inner as LlmResponseEnvelope;
}
return { ok: true, result: inner } as LlmResponseEnvelope;
}
return { ok: true, result: parsed } as LlmResponseEnvelope;
}
async function invokeHttpAdapter({
endpoint,
token,
payload,
}: {
endpoint: URL;
token: string;
payload: any;
}) {
const res = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : null),
},
body: JSON.stringify(payload),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 400)}`);
}
let parsed: any;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
throw new Error("Response was not JSON");
}
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
return parsed as LlmResponseEnvelope;
}
return { ok: true, result: parsed } as LlmResponseEnvelope;
}
function resolveModel(args: any, env: any, legacyEnvCompat: boolean | undefined) {
return resolveEnvString(
args.model,
["LOBSTER_LLM_MODEL", ...(legacyEnvCompat ? ["LLM_TASK_MODEL"] : [])],
env,
"",
);
}
function resolveEnvString(raw: any, envKeys: string[], env: any, fallback: string) {
if (raw !== undefined && raw !== null && String(raw).trim()) return String(raw).trim();
const fromEnv = getFirstEnv(env, envKeys);
if (fromEnv && String(fromEnv).trim()) return String(fromEnv).trim();
return fallback;
}
function getFirstEnv(env: any, keys: string[]) {
for (const key of keys) {
if (env?.[key] !== undefined && env?.[key] !== null && String(env[key]).trim()) {
return env[key];
}
}
return undefined;
}
function extractPrompt(args: any) {
if (args.prompt) return String(args.prompt);
if (Array.isArray(args._) && args._.length) {
return args._.join(" ");
}
return "";
}
function parseJsonArray(raw: any, label: string) {
if (!raw) return [];
try {
const parsed = JSON.parse(String(raw));
if (!Array.isArray(parsed)) throw new Error("must be array");
return parsed;
} catch {
throw new Error(`${label} must be a JSON array`);
}
}
function parseJsonObject(raw: any, label: string) {
if (!raw) return null;
try {
const parsed = JSON.parse(String(raw));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("must be an object");
}
return parsed;
} catch {
throw new Error(`${label} must be a JSON object`);
}
}
function parseOptionalNumber(value: any) {
if (value === undefined || value === null) return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function flag(value: any) {
if (value === undefined || value === null) return false;
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["false", "0", "no"].includes(normalized)) return false;
if (["true", "1", "yes"].includes(normalized)) return true;
}
return Boolean(value);
}
function normalizeArtifact(raw: any) {
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
return raw;
}
if (typeof raw === "string") {
return { kind: "text", text: raw };
}
if (typeof raw === "number" || typeof raw === "boolean") {
return { kind: "text", text: String(raw) };
}
return { kind: "json", data: raw };
}
function hashArtifact(artifact: any) {
const stable = stableStringify(artifact);
return createHash("sha256").update(stable).digest("hex");
}
function computeCacheKey({
provider,
prompt,
model,
schemaVersion,
artifactHashes,
outputSchema,
}: {
provider: SupportedProvider;
prompt: string;
model: string;
schemaVersion: string;
artifactHashes: string[];
outputSchema: any;
}) {
const payload = {
provider,
prompt,
model: model || `${provider}-default`,
schemaVersion,
artifactHashes,
outputSchema: outputSchema ?? null,
};
return createHash("sha256").update(stableStringify(payload)).digest("hex");
}
function normalizeResult({
envelope,
cacheKey,
schemaVersion,
artifactHashes,
source,
attempt,
itemKind,
}: {
envelope: LlmResponseEnvelope;
cacheKey: string;
schemaVersion: string;
artifactHashes: string[];
source: string;
attempt: number;
itemKind: string;
}): NormalizedInvocationItem[] {
const result = envelope.result ?? {};
const output = result.output ?? {};
const item: NormalizedInvocationItem = {
kind: itemKind,
runId: (result.runId ?? null) as any,
prompt: (result.prompt ?? null) as any,
model: (result.model ?? null) as any,
schemaVersion,
status: String(result.status ?? "completed"),
cacheKey,
artifactHashes,
output: {
format: (output.format ?? (output.data ? "json" : "text")) as any,
text: (output.text ?? null) as any,
data: (output.data ?? null) as any,
},
usage: (result.usage ?? null) as any,
metadata: (result.metadata ?? null) as any,
warnings: (result.warnings ?? null) as any,
diagnostics: (result.diagnostics ?? null) as any,
createdAt: new Date().toISOString(),
source,
cached:
source !== "remote" &&
source !== "openclaw" &&
source !== "clawd" &&
source !== "pi" &&
source !== "http",
attemptCount: attempt,
};
return [item];
}
async function persistOutputs({
env,
stateKey,
cacheKey,
items,
stateType,
}: {
env: any;
stateKey: string | null;
cacheKey: string;
items: NormalizedInvocationItem[];
stateType: string;
}) {
if (!stateKey) return;
const record = {
type: stateType,
version: STATE_VERSION,
cacheKey,
items,
storedAt: new Date().toISOString(),
};
await writeStateJson({ env, key: stateKey, value: record });
}
function pickReusableState(stored: any, cacheKey: string, stateType: string) {
if (!stored || typeof stored !== "object") return null;
if (stored.type !== stateType) return null;
if (stored.cacheKey !== cacheKey) return null;
if (!Array.isArray(stored.items)) return null;
return { items: stored.items as NormalizedInvocationItem[] };
}
function collectAjvErrors(errors: ErrorObject[] | null | undefined) {
if (!errors?.length) return [];
return errors.map((err) => `${err.instancePath || "/"} ${err.message ?? ""}`.trim());
}
async function readCacheEntry(
env: any,
key: string,
cacheNamespace: string,
): Promise<CacheEntry | null> {
const filePath = path.join(getCacheDir(env), cacheNamespace, `${key}.json`);
try {
const text = await fsp.readFile(filePath, "utf8");
return JSON.parse(text) as CacheEntry;
} catch (err: any) {
if (err?.code === "ENOENT") return null;
throw err;
}
}
async function writeCacheEntry(
env: any,
key: string,
items: NormalizedInvocationItem[],
cacheNamespace: string,
) {
const dir = path.join(getCacheDir(env), cacheNamespace);
await fsp.mkdir(dir, { recursive: true });
const filePath = path.join(dir, `${key}.json`);
await fsp.writeFile(
filePath,
JSON.stringify({ items, cacheKey: key, storedAt: new Date().toISOString() }, null, 2),
);
}
function getCacheDir(env: any) {
if (env?.LOBSTER_CACHE_DIR) return String(env.LOBSTER_CACHE_DIR);
return path.join(process.cwd(), ".lobster-cache");
}
async function* streamOf(items: any[]) {
for (const item of items) yield item;
}

View File

@ -1 +1,595 @@
export { llmTaskInvokeCommand } from "./llm_invoke.js";
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createHash } from 'node:crypto';
import { Ajv } from 'ajv';
import type { ErrorObject } from 'ajv';
import { readStateJson, writeStateJson, stableStringify } from '../../state/store.js';
const ajv = new Ajv({ allErrors: true, strict: false });
const artifactSchema = {
type: 'object',
properties: {
kind: { type: 'string' },
role: { type: 'string' },
name: { type: 'string' },
mimeType: { type: 'string' },
text: { type: 'string' },
data: {},
uri: { type: 'string' },
},
additionalProperties: true,
};
const payloadSchema = {
type: 'object',
properties: {
prompt: { type: 'string', minLength: 1 },
// In direct mode, the remote likely requires model; in CLAWD mode, Clawdbot can default.
model: { type: 'string', minLength: 1 },
artifacts: { type: 'array', items: artifactSchema },
artifactHashes: { type: 'array', items: { type: 'string', minLength: 10 } },
schemaVersion: { type: 'string' },
metadata: { type: 'object', additionalProperties: true },
outputSchema: { type: 'object', additionalProperties: true },
temperature: { type: 'number' },
maxOutputTokens: { type: 'number' },
retryContext: {
type: 'object',
properties: {
attempt: { type: 'number' },
validationErrors: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
},
},
required: ['prompt', 'artifacts', 'artifactHashes'],
additionalProperties: false,
};
const responseSchema = {
type: 'object',
properties: {
ok: { type: 'boolean' },
result: {
type: 'object',
properties: {
runId: { type: 'string' },
model: { type: 'string' },
prompt: { type: 'string' },
status: { type: 'string' },
output: {
type: 'object',
properties: {
text: { type: 'string' },
data: {},
format: { type: 'string' },
},
required: [],
additionalProperties: true,
},
usage: {
type: 'object',
properties: {
inputTokens: { type: 'number' },
outputTokens: { type: 'number' },
totalTokens: { type: 'number' },
},
additionalProperties: true,
},
warnings: { type: 'array', items: { type: 'string' } },
metadata: { type: 'object', additionalProperties: true },
diagnostics: { type: 'object', additionalProperties: true },
},
required: ['output'],
additionalProperties: true,
},
error: { type: 'object', additionalProperties: true },
},
required: ['ok'],
additionalProperties: true,
};
const validatePayload = ajv.compile(payloadSchema);
const validateResponseEnvelope = ajv.compile(responseSchema);
const DEFAULT_MAX_VALIDATION_RETRIES = 1;
const STATE_VERSION = 1;
type LlmTaskResponseEnvelope = {
ok: boolean;
result?: LlmTaskResponse | null;
error?: { message?: string } | null;
};
type LlmTaskResponse = {
runId?: string | null;
model?: string | null;
prompt?: string | null;
status?: string | null;
output?: {
text?: string | null;
data?: any;
format?: string | null;
} | null;
usage?: Record<string, unknown> | null;
warnings?: string[] | null;
metadata?: Record<string, unknown> | null;
diagnostics?: Record<string, unknown> | null;
};
type NormalizedInvocationItem = {
kind: 'llm_task.invoke';
runId: string | null;
prompt: string | null;
model: string | null;
schemaVersion: string | null;
status: string;
cacheKey: string;
artifactHashes: string[];
output: { format: string | null; text: string | null; data: any };
usage: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
warnings: string[] | null;
diagnostics: Record<string, unknown> | null;
createdAt: string;
source: string;
cached: boolean;
attemptCount: number;
};
type CacheEntry = {
items: NormalizedInvocationItem[];
cacheKey: string;
storedAt: string;
};
type Transport = 'clawd';
export const llmTaskInvokeCommand = {
name: 'llm_task.invoke',
meta: {
description: 'Call Clawdbot llm-task tool with typed payloads and caching',
argsSchema: {
type: 'object',
properties: {
token: {
type: 'string',
description: 'Bearer token (or CLAWD_TOKEN). Optional if unauthenticated.',
},
prompt: { type: 'string', description: 'Primary prompt / instructions' },
model: { type: 'string', description: 'Model identifier (optional; Clawdbot default will be used if omitted in CLAWD mode)' },
'artifacts-json': { type: 'string', description: 'JSON array of artifacts to send' },
'metadata-json': { type: 'string', description: 'JSON object of metadata to include' },
'output-schema': { type: 'string', description: 'JSON schema LLM output must satisfy' },
'schema-version': { type: 'string', description: 'Logical schema version for caching' },
'max-validation-retries': { type: 'number', description: 'Retries when schema validation fails' },
temperature: { type: 'number', description: 'Sampling temperature' },
'max-output-tokens': { type: 'number', description: 'Max completion tokens' },
'state-key': { type: 'string', description: 'Run-state key override (else LOBSTER_RUN_STATE_KEY)' },
refresh: { type: 'boolean', description: 'Bypass run-state + cache' },
'disable-cache': { type: 'boolean', description: 'Skip persistent cache' },
_: { type: 'array', items: { type: 'string' } },
},
required: [],
},
sideEffects: ['calls_llm_task'],
},
help() {
return (
`llm_task.invoke — call Clawdbot llm-task tool with caching and schema validation\n\n` +
`Usage:\n` +
` llm_task.invoke --prompt 'Write summary'\n` +
` llm_task.invoke --model claude-3-sonnet --prompt 'Write summary'\n` +
` cat artifacts.json | llm_task.invoke --prompt 'Score each item'\n` +
` ... | llm_task.invoke --prompt 'Plan next steps' --output-schema '{"type":"object"}'\n\n` +
`Config:\n` +
` - Requires CLAWD_URL (Clawdbot gateway).\n` +
` - Optional CLAWD_TOKEN for auth.\n\n` +
`Features:\n` +
` - Typed payload validation before invoking tool.\n` +
` - Run-state + file cache so resumes do not re-call the LLM.\n` +
` - Optional JSON-schema enforcement with bounded retries.\n`
);
},
async run({ input, args, ctx }) {
const env = ctx.env ?? process.env;
const clawdUrl = String(env.CLAWD_URL ?? '').trim();
const transport: Transport = 'clawd';
if (!clawdUrl) {
throw new Error('llm_task.invoke requires CLAWD_URL (run via Clawdbot gateway)');
}
const prompt = extractPrompt(args);
if (!prompt) throw new Error('llm_task.invoke requires --prompt or positional text');
const model = String(args.model ?? env.LLM_TASK_MODEL ?? '').trim();
// Model is optional in Clawdbot mode (Clawdbot llm-task tool can use its default model).
const schemaVersion = args['schema-version']
? String(args['schema-version']).trim()
: env.LLM_TASK_SCHEMA_VERSION
? String(env.LLM_TASK_SCHEMA_VERSION).trim()
: 'v1';
const maxOutputTokens = parseOptionalNumber(args['max-output-tokens']);
const temperature = parseOptionalNumber(args.temperature);
const providedArtifacts = parseJsonArray(args['artifacts-json'], 'llm_task.invoke --artifacts-json');
const metadataObject = parseJsonObject(args['metadata-json'], 'llm_task.invoke --metadata-json');
const userOutputSchema = parseJsonObject(args['output-schema'], 'llm_task.invoke --output-schema');
const maxValidationRetriesRaw = args['max-validation-retries'] ?? env.LLM_TASK_VALIDATION_RETRIES;
const maxValidationRetries = userOutputSchema
? Math.max(0, Number.isFinite(Number(maxValidationRetriesRaw))
? Number(maxValidationRetriesRaw)
: DEFAULT_MAX_VALIDATION_RETRIES)
: 0;
const disableCache = flag(args['disable-cache']);
const forceRefresh = flag(args.refresh ?? env.LLM_TASK_FORCE_REFRESH);
const stateKey = String(args['state-key'] ?? env.LOBSTER_RUN_STATE_KEY ?? '').trim() || null;
const inputArtifacts: any[] = [];
for await (const item of input) inputArtifacts.push(item);
const normalizedArtifacts = [...inputArtifacts, ...providedArtifacts].map(normalizeArtifact);
const artifactHashes = normalizedArtifacts.map(hashArtifact);
const cacheKey = computeCacheKey({ prompt, model, schemaVersion, artifactHashes, outputSchema: userOutputSchema });
if (stateKey && !forceRefresh) {
const stored = await readStateJson({ env, key: stateKey }).catch(() => null);
const reused = pickReusableState(stored, cacheKey);
if (reused) {
return {
output: streamOf(reused.items.map((item) => ({ ...item, source: 'run_state', cached: true }))),
};
}
}
if (!disableCache && !forceRefresh) {
const cache = await readCacheEntry(env, cacheKey);
if (cache) {
return {
output: streamOf(cache.items.map((item: any) => ({ ...item, source: 'cache', cached: true }))),
};
}
}
const payload: Record<string, any> = {
prompt,
...(model ? { model } : null),
artifacts: normalizedArtifacts,
artifactHashes,
};
if (metadataObject) payload.metadata = metadataObject;
if (userOutputSchema) payload.outputSchema = userOutputSchema;
if (schemaVersion) payload.schemaVersion = schemaVersion;
if (Number.isFinite(maxOutputTokens ?? NaN)) payload.maxOutputTokens = Number(maxOutputTokens);
if (Number.isFinite(temperature ?? NaN)) payload.temperature = Number(temperature);
if (!validatePayload(payload)) {
throw new Error(`llm_task.invoke payload invalid: ${ajv.errorsText(validatePayload.errors)}`);
}
const endpoint = buildClawdEndpoint(clawdUrl);
const token = String(args.token ?? env.CLAWD_TOKEN ?? '').trim();
const validator = userOutputSchema ? ajv.compile(userOutputSchema) : null;
let attempt = 0;
let lastValidationErrors: string[] = [];
while (true) {
attempt++;
if (attempt > 1) {
payload.retryContext = {
attempt,
...(lastValidationErrors.length ? { validationErrors: lastValidationErrors } : null),
};
} else {
delete payload.retryContext;
}
let responseEnvelope: LlmTaskResponseEnvelope;
try {
responseEnvelope = await invokeRemoteViaClawd({ endpoint, token, payload });
} catch (err: any) {
throw new Error(`llm_task.invoke request failed: ${err?.message ?? String(err)}`);
}
if (!validateResponseEnvelope(responseEnvelope)) {
throw new Error(`llm_task.invoke received invalid response envelope`);
}
if (responseEnvelope.ok !== true) {
const message = responseEnvelope.error?.message ?? 'llm-task returned an error';
throw new Error(`llm_task.invoke remote error: ${message}`);
}
const normalized = normalizeResult({
envelope: responseEnvelope,
cacheKey,
schemaVersion,
artifactHashes,
source: 'clawd',
attempt,
});
if (!validator) {
await persistOutputs({ env, stateKey, cacheKey, items: normalized });
if (!disableCache) await writeCacheEntry(env, cacheKey, normalized);
return { output: streamOf(normalized) };
}
const structured = normalized[0]?.output?.data ?? null;
if (validator(structured)) {
await persistOutputs({ env, stateKey, cacheKey, items: normalized });
if (!disableCache) await writeCacheEntry(env, cacheKey, normalized);
return { output: streamOf(normalized) };
}
lastValidationErrors = collectAjvErrors(validator.errors);
if (attempt > maxValidationRetries + 1) {
throw new Error(`llm_task.invoke output failed schema validation: ${lastValidationErrors.join('; ')}`);
}
}
},
};
function extractPrompt(args: any) {
if (args.prompt) return String(args.prompt);
if (Array.isArray(args._) && args._.length) {
return args._.join(' ');
}
return '';
}
function parseJsonArray(raw: any, label: string) {
if (!raw) return [];
try {
const parsed = JSON.parse(String(raw));
if (!Array.isArray(parsed)) throw new Error('must be array');
return parsed;
} catch {
throw new Error(`${label} must be a JSON array`);
}
}
function parseJsonObject(raw: any, label: string) {
if (!raw) return null;
try {
const parsed = JSON.parse(String(raw));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('must be an object');
}
return parsed;
} catch {
throw new Error(`${label} must be a JSON object`);
}
}
function parseOptionalNumber(value: any) {
if (value === undefined || value === null) return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function flag(value: any) {
if (value === undefined || value === null) return false;
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no'].includes(normalized)) return false;
if (['true', '1', 'yes'].includes(normalized)) return true;
}
return Boolean(value);
}
function normalizeArtifact(raw: any) {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw;
}
if (typeof raw === 'string') {
return { kind: 'text', text: raw };
}
if (typeof raw === 'number' || typeof raw === 'boolean') {
return { kind: 'text', text: String(raw) };
}
return { kind: 'json', data: raw };
}
function hashArtifact(artifact: any) {
const stable = stableStringify(artifact);
return createHash('sha256').update(stable).digest('hex');
}
function computeCacheKey({
prompt,
model,
schemaVersion,
artifactHashes,
outputSchema,
}: {
prompt: string;
model: string;
schemaVersion: string;
artifactHashes: string[];
outputSchema: any;
}) {
const payload = {
prompt,
// If model is omitted (Clawdbot default), keep caching stable but explicit.
model: model || 'clawd-default',
schemaVersion,
artifactHashes,
outputSchema: outputSchema ?? null,
};
return createHash('sha256').update(stableStringify(payload)).digest('hex');
}
function buildClawdEndpoint(clawdUrl: string) {
return new URL('/tools/invoke', clawdUrl);
}
async function invokeRemoteViaClawd({ endpoint, token, payload }: { endpoint: URL; token: string; payload: any }) {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : null),
},
body: JSON.stringify({
tool: 'llm-task',
action: 'invoke',
args: payload,
}),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 400)}`);
}
let parsed: any;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
throw new Error('Response was not JSON');
}
// Clawdbot tool router envelope: { ok, result, error }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'ok' in parsed) {
if (parsed.ok !== true) {
const msg = parsed?.error?.message ?? 'Unknown error';
throw new Error(`clawd tool error: ${msg}`);
}
const inner = parsed.result;
// llm-task tool likely returns its own { ok, result } envelope.
if (inner && typeof inner === 'object' && !Array.isArray(inner) && 'ok' in inner) {
return inner as LlmTaskResponseEnvelope;
}
// Otherwise treat it as raw result.
return { ok: true, result: inner } as LlmTaskResponseEnvelope;
}
// Compatibility: raw JSON
return { ok: true, result: parsed } as LlmTaskResponseEnvelope;
}
function normalizeResult({
envelope,
cacheKey,
schemaVersion,
artifactHashes,
source,
attempt,
}: {
envelope: LlmTaskResponseEnvelope;
cacheKey: string;
schemaVersion: string;
artifactHashes: string[];
source: string;
attempt: number;
}): NormalizedInvocationItem[] {
const result = envelope.result ?? {};
const output = result.output ?? {};
const item: NormalizedInvocationItem = {
kind: 'llm_task.invoke',
runId: (result.runId ?? null) as any,
prompt: (result.prompt ?? null) as any,
model: (result.model ?? null) as any,
schemaVersion,
status: String(result.status ?? 'completed'),
cacheKey,
artifactHashes,
output: {
format: (output.format ?? (output.data ? 'json' : 'text')) as any,
text: (output.text ?? null) as any,
data: (output.data ?? null) as any,
},
usage: (result.usage ?? null) as any,
metadata: (result.metadata ?? null) as any,
warnings: (result.warnings ?? null) as any,
diagnostics: (result.diagnostics ?? null) as any,
createdAt: new Date().toISOString(),
source,
cached: source !== 'remote' && source !== 'clawd',
attemptCount: attempt,
};
return [item];
}
async function persistOutputs({
env,
stateKey,
cacheKey,
items,
}: {
env: any;
stateKey: string | null;
cacheKey: string;
items: NormalizedInvocationItem[];
}) {
if (!stateKey) return;
const record = {
type: 'llm_task.invoke',
version: STATE_VERSION,
cacheKey,
items,
storedAt: new Date().toISOString(),
};
await writeStateJson({ env, key: stateKey, value: record });
}
function pickReusableState(stored: any, cacheKey: string) {
if (!stored || typeof stored !== 'object') return null;
if (stored.type !== 'llm_task.invoke') return null;
if (stored.cacheKey !== cacheKey) return null;
if (!Array.isArray(stored.items)) return null;
return { items: stored.items as NormalizedInvocationItem[] };
}
function collectAjvErrors(errors: ErrorObject[] | null | undefined) {
if (!errors?.length) return [];
return errors.map((err) => `${err.instancePath || '/'} ${err.message ?? ''}`.trim());
}
async function readCacheEntry(env: any, key: string): Promise<CacheEntry | null> {
const filePath = path.join(getCacheDir(env), 'llm_task.invoke', `${key}.json`);
try {
const text = await fsp.readFile(filePath, 'utf8');
return JSON.parse(text) as CacheEntry;
} catch (err: any) {
if (err?.code === 'ENOENT') return null;
throw err;
}
}
async function writeCacheEntry(env: any, key: string, items: NormalizedInvocationItem[]) {
const dir = path.join(getCacheDir(env), 'llm_task.invoke');
await fsp.mkdir(dir, { recursive: true });
const filePath = path.join(dir, `${key}.json`);
await fsp.writeFile(
filePath,
JSON.stringify({ items, cacheKey: key, storedAt: new Date().toISOString() }, null, 2),
);
}
function getCacheDir(env: any) {
if (env?.LOBSTER_CACHE_DIR) return String(env.LOBSTER_CACHE_DIR);
return path.join(process.cwd(), '.lobster-cache');
}
async function* streamOf(items: any[]) {
for (const item of items) yield item;
}

View File

@ -1,6 +1,6 @@
function getByPath(obj: any, path: string): any {
if (path === "." || path === "this") return obj;
const parts = path.split(".").filter(Boolean);
if (path === '.' || path === 'this') return obj;
const parts = path.split('.').filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -11,10 +11,10 @@ function getByPath(obj: any, path: string): any {
function renderTemplate(tpl: string, ctx: any): string {
return tpl.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_m, expr) => {
const key = String(expr ?? "").trim();
const key = String(expr ?? '').trim();
const val = getByPath(ctx, key);
if (val === undefined || val === null) return "";
if (typeof val === "string") return val;
if (val === undefined || val === null) return '';
if (typeof val === 'string') return val;
return JSON.stringify(val);
});
}
@ -23,7 +23,7 @@ function parseAssignments(tokens: any[]): Array<{ key: string; value: string }>
const out: Array<{ key: string; value: string }> = [];
for (const tok of tokens ?? []) {
const s = String(tok);
const idx = s.indexOf("=");
const idx = s.indexOf('=');
if (idx === -1) continue;
const key = s.slice(0, idx).trim();
const value = s.slice(idx + 1);
@ -34,19 +34,15 @@ function parseAssignments(tokens: any[]): Array<{ key: string; value: string }>
}
export const mapCommand = {
name: "map",
name: 'map',
meta: {
description: "Transform items (wrap/unwrap/add fields)",
description: 'Transform items (wrap/unwrap/add fields)',
argsSchema: {
type: "object",
type: 'object',
properties: {
wrap: { type: "string", description: "Wrap each item as {wrap: item}" },
unwrap: { type: "string", description: "Unwrap a field (yield item[unwrap])" },
_: {
type: "array",
items: { type: "string" },
description: "Optional assignments like key=value (value supports {{path}})",
},
wrap: { type: 'string', description: 'Wrap each item as {wrap: item}' },
unwrap: { type: 'string', description: 'Unwrap a field (yield item[unwrap])' },
_: { type: 'array', items: { type: 'string' }, description: 'Optional assignments like key=value (value supports {{path}})' },
},
required: [],
},
@ -65,11 +61,11 @@ export const mapCommand = {
);
},
async run({ input, args }: any) {
const wrap = typeof args.wrap === "string" ? args.wrap : undefined;
const unwrap = typeof args.unwrap === "string" ? args.unwrap : undefined;
const wrap = typeof args.wrap === 'string' ? args.wrap : undefined;
const unwrap = typeof args.unwrap === 'string' ? args.unwrap : undefined;
const assignments = parseAssignments(Array.isArray(args._) ? args._ : []);
if (wrap && unwrap) throw new Error("map cannot use both --wrap and --unwrap");
if (wrap && unwrap) throw new Error('map cannot use both --wrap and --unwrap');
return {
output: (async function* () {
@ -77,7 +73,7 @@ export const mapCommand = {
let cur: any = item;
if (unwrap) {
if (cur && typeof cur === "object") cur = cur[unwrap];
if (cur && typeof cur === 'object') cur = cur[unwrap];
else cur = undefined;
yield cur;
continue;
@ -88,7 +84,7 @@ export const mapCommand = {
}
if (assignments.length > 0) {
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) {
if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) {
// If current is not an object, turn it into one so we can attach fields.
cur = { value: cur };
}

View File

@ -1,150 +0,0 @@
function createInvokeCommand(commandName: string) {
return {
name: commandName,
meta: {
description: "Call a local OpenClaw tool endpoint",
argsSchema: {
type: "object",
properties: {
url: {
type: "string",
description: "OpenClaw control URL (or OPENCLAW_URL / CLAWD_URL)",
},
token: { type: "string", description: "Bearer token (or OPENCLAW_TOKEN / CLAWD_TOKEN)" },
tool: { type: "string", description: "Tool name (e.g. message, cron, github, etc.)" },
action: { type: "string", description: "Tool action" },
"args-json": { type: "string", description: "JSON string of tool args" },
sessionKey: { type: "string", description: "Optional session key attribution" },
"session-key": { type: "string", description: "Alias for sessionKey" },
dryRun: { type: "boolean", description: "Dry run" },
"dry-run": { type: "boolean", description: "Alias for dryRun" },
each: { type: "boolean", description: "Map each pipeline item into tool args" },
itemKey: {
type: "string",
description: "Key to set from the pipeline item (default: item)",
},
"item-key": { type: "string", description: "Alias for itemKey" },
_: { type: "array", items: { type: "string" } },
},
required: ["tool", "action"],
},
sideEffects: ["calls_clawd_tool"],
},
help() {
return (
`${commandName} — call a local OpenClaw tool endpoint\n\n` +
`Usage:\n` +
` ${commandName} --tool message --action send --args-json '{"provider":"telegram","to":"...","message":"..."}'\n` +
` ${commandName} --tool message --action send --args-json '{...}' --dry-run\n` +
` ... | ${commandName} --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'\n\n` +
`Config:\n` +
` - Uses OPENCLAW_URL env var by default (or pass --url).\n` +
` - Backward compatible: CLAWD_URL is also supported.\n` +
` - Optional Bearer token via OPENCLAW_TOKEN env var (or pass --token).\n` +
` - Backward compatible: CLAWD_TOKEN is also supported.\n` +
` - Optional attribution via --session-key <sessionKey>.\n\n` +
`Notes:\n` +
` - This is a thin transport bridge. Lobster should not own OAuth/secrets.\n`
);
},
async run({ input, args, ctx }) {
const each = Boolean(args.each);
const itemKey = String(args.itemKey ?? args["item-key"] ?? "item");
const url = String(args.url ?? ctx.env.OPENCLAW_URL ?? ctx.env.CLAWD_URL ?? "").trim();
if (!url) throw new Error(`${commandName} requires --url or OPENCLAW_URL`);
const tool = args.tool;
const action = args.action;
if (!tool || !action) throw new Error(`${commandName} requires --tool and --action`);
const token = String(
args.token ?? ctx.env.OPENCLAW_TOKEN ?? ctx.env.CLAWD_TOKEN ?? "",
).trim();
let toolArgs: any = {};
if (args["args-json"]) {
try {
toolArgs = JSON.parse(String(args["args-json"]));
} catch (_err) {
throw new Error(`${commandName} --args-json must be valid JSON`);
}
}
if (each && (toolArgs === null || typeof toolArgs !== "object" || Array.isArray(toolArgs))) {
throw new Error(`${commandName} --each requires --args-json to be an object`);
}
const endpoint = new URL("/tools/invoke", url);
const sessionKey = args.sessionKey ?? args["session-key"] ?? null;
const dryRun = args.dryRun ?? args["dry-run"] ?? null;
const invokeOnce = async (argsValue: unknown) => {
const res = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : null),
},
body: JSON.stringify({
tool: String(tool),
action: String(action),
args: argsValue,
...(sessionKey ? { sessionKey: String(sessionKey) } : null),
...(dryRun !== null ? { dryRun: Boolean(dryRun) } : null),
}),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`${commandName} failed (${res.status}): ${text.slice(0, 400)}`);
}
let parsed: any;
try {
parsed = text ? JSON.parse(text) : null;
} catch (_err) {
throw new Error(`${commandName} expected JSON response`);
}
// Preferred: { ok: true, result: ... }
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
if (parsed.ok !== true) {
const msg = parsed?.error?.message ?? "Unknown error";
throw new Error(`${commandName} tool error: ${msg}`);
}
const result = parsed.result;
return Array.isArray(result) ? result : [result];
}
// Compatibility: raw JSON result
return Array.isArray(parsed) ? parsed : [parsed];
};
if (!each) {
// Drain input: for now we don't stream input into clawd calls.
for await (const _item of input) {
// no-op
}
const items = await invokeOnce(toolArgs);
return { output: asStream(items) };
}
const out: any[] = [];
for await (const item of input) {
const argsValue = { ...(toolArgs as any), [itemKey]: item };
const items = await invokeOnce(argsValue);
out.push(...items);
}
return { output: asStream(out) };
},
};
}
async function* asStream(items: any[]) {
for (const item of items) yield item;
}
export const openclawInvokeCommand = createInvokeCommand("openclaw.invoke");
export const clawdInvokeCommand = createInvokeCommand("clawd.invoke");

View File

@ -1,17 +1,17 @@
export const pickCommand = {
name: "pick",
name: 'pick',
meta: {
description: "Project fields from objects",
description: 'Project fields from objects',
argsSchema: {
type: "object",
type: 'object',
properties: {
_: {
type: "array",
items: { type: "string" },
description: "First positional arg is a comma-separated list of fields",
type: 'array',
items: { type: 'string' },
description: 'First positional arg is a comma-separated list of fields',
},
},
required: ["_"],
required: ['_'],
},
sideEffects: [],
},
@ -20,16 +20,13 @@ export const pickCommand = {
},
async run({ input, args }) {
const spec = args._[0];
if (!spec) throw new Error("pick requires a comma-separated field list");
const fields = spec
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!spec) throw new Error('pick requires a comma-separated field list');
const fields = spec.split(',').map((s) => s.trim()).filter(Boolean);
return {
output: (async function* () {
for await (const item of input) {
if (item === null || typeof item !== "object") {
if (item === null || typeof item !== 'object') {
yield item;
continue;
}

View File

@ -1,6 +1,6 @@
function getByPath(obj: any, path: string): any {
if (!path) return undefined;
const parts = path.split(".").filter(Boolean);
const parts = path.split('.').filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -18,26 +18,23 @@ function defaultCompare(a: any, b: any): number {
if (bU) return -1;
// number compare if both numbers
if (typeof a === "number" && typeof b === "number") return a - b;
if (typeof a === 'number' && typeof b === 'number') return a - b;
// Deterministic lexical compare independent of process locale.
const aStr = String(a);
const bStr = String(b);
if (aStr < bStr) return -1;
if (aStr > bStr) return 1;
return 0;
// date-ish compare if both look like ISO/date strings?
// Keep it simple: string compare for everything else.
return String(a).localeCompare(String(b));
}
export const sortCommand = {
name: "sort",
name: 'sort',
meta: {
description: "Sort items (stable) by a key or by stringified value",
description: 'Sort items (stable) by a key or by stringified value',
argsSchema: {
type: "object",
type: 'object',
properties: {
key: { type: "string", description: "Dot-path key to sort by (e.g. updatedAt, pr.number)" },
desc: { type: "boolean", description: "Sort descending" },
_: { type: "array", items: { type: "string" } },
key: { type: 'string', description: 'Dot-path key to sort by (e.g. updatedAt, pr.number)' },
desc: { type: 'boolean', description: 'Sort descending' },
_: { type: 'array', items: { type: 'string' } },
},
required: [],
},
@ -56,7 +53,7 @@ export const sortCommand = {
);
},
async run({ input, args }: any) {
const key = typeof args.key === "string" ? args.key : undefined;
const key = typeof args.key === 'string' ? args.key : undefined;
const desc = Boolean(args.desc);
const items: any[] = [];

View File

@ -1,36 +1,36 @@
import { promises as fsp } from "node:fs";
import { promises as fsp } from 'node:fs';
import { defaultStateDir, keyToPath } from "../../state/store.js";
import { defaultStateDir, keyToPath } from '../../state/store.js';
export const stateGetCommand = {
name: "state.get",
name: 'state.get',
meta: {
description: "Read a JSON value from Lobster state",
description: 'Read a JSON value from Lobster state',
argsSchema: {
type: "object",
type: 'object',
properties: {
_: { type: "array", items: { type: "string" }, description: "Key" },
_: { type: 'array', items: { type: 'string' }, description: 'Key' },
},
required: ["_"],
required: ['_'],
},
sideEffects: ["reads_state"],
sideEffects: ['reads_state'],
},
help() {
return `state.get — read a JSON value from Lobster state\n\nUsage:\n state.get <key>\n\nEnv:\n LOBSTER_STATE_DIR overrides storage directory\n`;
},
async run({ args, ctx }) {
const key = args._[0];
if (!key) throw new Error("state.get requires a key");
if (!key) throw new Error('state.get requires a key');
const stateDir = defaultStateDir(ctx.env);
const filePath = keyToPath(stateDir, key);
let value = null;
try {
const text = await fsp.readFile(filePath, "utf8");
const text = await fsp.readFile(filePath, 'utf8');
value = JSON.parse(text);
} catch (err) {
if (err?.code === "ENOENT") {
if (err?.code === 'ENOENT') {
value = null;
} else {
throw err;
@ -42,24 +42,24 @@ export const stateGetCommand = {
};
export const stateSetCommand = {
name: "state.set",
name: 'state.set',
meta: {
description: "Write a JSON value to Lobster state",
description: 'Write a JSON value to Lobster state',
argsSchema: {
type: "object",
type: 'object',
properties: {
_: { type: "array", items: { type: "string" }, description: "Key" },
_: { type: 'array', items: { type: 'string' }, description: 'Key' },
},
required: ["_"],
required: ['_'],
},
sideEffects: ["writes_state"],
sideEffects: ['writes_state'],
},
help() {
return `state.set — write a JSON value to Lobster state\n\nUsage:\n <value> | state.set <key>\n\nNotes:\n - Consumes the entire input stream; stores a single JSON value.\n`;
},
async run({ input, args, ctx }) {
const key = args._[0];
if (!key) throw new Error("state.set requires a key");
if (!key) throw new Error('state.set requires a key');
const items = [];
for await (const item of input) items.push(item);
@ -70,7 +70,7 @@ export const stateSetCommand = {
const filePath = keyToPath(stateDir, key);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
return { output: asStream([value]) };
},

View File

@ -1,15 +1,15 @@
function stringifyCell(v) {
if (v === null || v === undefined) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
if (v === null || v === undefined) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return JSON.stringify(v);
}
export const tableCommand = {
name: "table",
name: 'table',
meta: {
description: "Render items as a simple table",
argsSchema: { type: "object", properties: {}, required: [] },
description: 'Render items as a simple table',
argsSchema: { type: 'object', properties: {}, required: [] },
sideEffects: [],
},
help() {
@ -20,12 +20,12 @@ export const tableCommand = {
for await (const item of input) items.push(item);
if (items.length === 0) {
ctx.stdout.write("(no results)\n");
ctx.stdout.write('(no results)\n');
return { output: emptyStream(), rendered: true };
}
const sample = items.slice(0, 20);
const objectItems = sample.filter((x) => x && typeof x === "object" && !Array.isArray(x));
const objectItems = sample.filter((x) => x && typeof x === 'object' && !Array.isArray(x));
if (objectItems.length === sample.length) {
const cols = [];
@ -39,22 +39,21 @@ export const tableCommand = {
}
}
const rows = [cols, ...items.map((it) => cols.map((c) => stringifyCell(it?.[c])))].map(
(row) => row.map((cell) => cell.replace(/\n/g, " ")),
);
const rows = [cols, ...items.map((it) => cols.map((c) => stringifyCell(it?.[c])))]
.map((row) => row.map((cell) => cell.replace(/\n/g, ' ')));
const widths = cols.map((_, i) => Math.max(...rows.map((r) => r[i].length), 3));
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
ctx.stdout.write(renderRow(rows[0]) + "\n");
ctx.stdout.write(widths.map((w) => "-".repeat(w)).join(" ") + "\n");
for (const row of rows.slice(1)) ctx.stdout.write(renderRow(row) + "\n");
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(' ');
ctx.stdout.write(renderRow(rows[0]) + '\n');
ctx.stdout.write(widths.map((w) => '-'.repeat(w)).join(' ') + '\n');
for (const row of rows.slice(1)) ctx.stdout.write(renderRow(row) + '\n');
return { output: emptyStream(), rendered: true };
}
// Fallback: render each item on a line.
for (const item of items) ctx.stdout.write(stringifyCell(item) + "\n");
for (const item of items) ctx.stdout.write(stringifyCell(item) + '\n');
return { output: emptyStream(), rendered: true };
},
};

View File

@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import { applyFilters } from "../../core/filters.js";
import fs from 'node:fs/promises';
function getByPath(obj: any, path: string): any {
if (path === "." || path === "this") return obj;
const parts = path.split(".").filter(Boolean);
if (path === '.' || path === 'this') return obj;
const parts = path.split('.').filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -12,72 +11,26 @@ function getByPath(obj: any, path: string): any {
return cur;
}
function splitFilterChain(expr: string): string[] {
const parts: string[] = [];
let current = "";
let i = 0;
while (i < expr.length) {
const ch = expr[i];
if (ch === '"' || ch === "'") {
const quote = ch;
current += ch;
i += 1;
while (i < expr.length && expr[i] !== quote) {
if (expr[i] === "\\" && i + 1 < expr.length) {
current += expr[i] + expr[i + 1];
i += 2;
} else {
current += expr[i];
i += 1;
}
}
if (i < expr.length) {
current += expr[i];
i += 1;
}
} else if (ch === "|") {
parts.push(current.trim());
current = "";
i += 1;
} else {
current += ch;
i += 1;
}
}
parts.push(current.trim());
return parts;
}
function renderTemplate(tpl: string, ctx: any): string {
return tpl.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_m, expr) => {
const rawExpr = String(expr ?? "").trim();
const parts = splitFilterChain(rawExpr);
const key = parts[0];
let val: unknown = getByPath(ctx, key);
if (parts.length > 1) {
val = applyFilters(val, parts.slice(1));
}
if (val === undefined || val === null) return "";
if (typeof val === "string") return val;
if (typeof val === "number" || typeof val === "boolean") return String(val);
const key = String(expr ?? '').trim();
const val = getByPath(ctx, key);
if (val === undefined || val === null) return '';
if (typeof val === 'string') return val;
return JSON.stringify(val);
});
}
export const templateCommand = {
name: "template",
name: 'template',
meta: {
description: "Render a simple {{path}} template against each input item",
description: 'Render a simple {{path}} template against each input item',
argsSchema: {
type: "object",
type: 'object',
properties: {
text: {
type: "string",
description:
"Template text (supports {{path}}, {{path | filter}}, {{.}} for the whole item)",
},
file: { type: "string", description: "Template file path" },
_: { type: "array", items: { type: "string" } },
text: { type: 'string', description: 'Template text (supports {{path}}; {{.}} for the whole item)' },
file: { type: 'string', description: 'Template file path' },
_: { type: 'array', items: { type: 'string' } },
},
required: [],
},
@ -92,28 +45,23 @@ export const templateCommand = {
`Template syntax:\n` +
` - {{field}} or {{nested.field}}\n` +
` - {{.}} for the whole item\n` +
` - {{field | filter}} with pipe-based filters\n` +
` - Missing values render as empty string\n\n` +
`Filters:\n` +
` upper, lower, trim, truncate N, replace "from" "to", split sep\n` +
` first, last, length, join sep\n` +
` json, string, default val, round N, date fmt\n`
` - Missing values render as empty string\n`
);
},
async run({ input, args }: any) {
let tpl = typeof args.text === "string" ? args.text : undefined;
const file = typeof args.file === "string" ? args.file : undefined;
let tpl = typeof args.text === 'string' ? args.text : undefined;
const file = typeof args.file === 'string' ? args.file : undefined;
if (!tpl && file) {
tpl = await fs.readFile(file, "utf8");
tpl = await fs.readFile(file, 'utf8');
}
if (!tpl) {
const positional = Array.isArray(args._) ? args._ : [];
if (positional.length) tpl = positional.join(" ");
if (positional.length) tpl = positional.join(' ');
}
if (!tpl) throw new Error("template requires --text or --file (or positional text)");
if (!tpl) throw new Error('template requires --text or --file (or positional text)');
return {
output: (async function* () {

View File

@ -4,19 +4,19 @@ function parsePredicate(expr) {
const [, path, op, rawValue] = m;
let value = rawValue;
if (rawValue === "true") value = true;
else if (rawValue === "false") value = false;
else if (rawValue === "null") value = null;
else if (!Number.isNaN(Number(rawValue)) && rawValue.trim() !== "") value = Number(rawValue);
if (rawValue === 'true') value = true;
else if (rawValue === 'false') value = false;
else if (rawValue === 'null') value = null;
else if (!Number.isNaN(Number(rawValue)) && rawValue.trim() !== '') value = Number(rawValue);
return { path, op: op === "=" ? "==" : op, value };
return { path, op: op === '=' ? '==' : op, value };
}
function getPath(obj, path) {
const parts = path.split(".");
const parts = path.split('.');
let cur = obj;
for (const p of parts) {
if (cur === null || typeof cur !== "object") return undefined;
if (cur === null || typeof cur !== 'object') return undefined;
cur = cur[p];
}
return cur;
@ -24,37 +24,30 @@ function getPath(obj, path) {
function compare(left, op, right) {
switch (op) {
case "==":
return left == right; // intentional loose equality for convenience
case "!=":
return left != right;
case "<":
return left < right;
case "<=":
return left <= right;
case ">":
return left > right;
case ">=":
return left >= right;
default:
throw new Error(`Unsupported operator: ${op}`);
case '==': return left == right; // intentional loose equality for convenience
case '!=': return left != right;
case '<': return left < right;
case '<=': return left <= right;
case '>': return left > right;
case '>=': return left >= right;
default: throw new Error(`Unsupported operator: ${op}`);
}
}
export const whereCommand = {
name: "where",
name: 'where',
meta: {
description: "Filter objects by a simple predicate",
description: 'Filter objects by a simple predicate',
argsSchema: {
type: "object",
type: 'object',
properties: {
_: {
type: "array",
items: { type: "string" },
description: "First positional arg is an expression like field=value or minutes>=30",
type: 'array',
items: { type: 'string' },
description: 'First positional arg is an expression like field=value or minutes>=30',
},
},
required: ["_"],
required: ['_'],
},
sideEffects: [],
},
@ -63,7 +56,7 @@ export const whereCommand = {
},
async run({ input, args }) {
const expr = args._[0];
if (!expr) throw new Error("where requires an expression (e.g. field=value)");
if (!expr) throw new Error('where requires an expression (e.g. field=value)');
const pred = parsePredicate(expr);
return {

View File

@ -1,14 +1,14 @@
import { listWorkflows } from "../../workflows/registry.js";
import { listWorkflows } from '../../workflows/registry.js';
export const workflowsListCommand = {
name: "workflows.list",
name: 'workflows.list',
meta: {
description: "List available Lobster workflows",
argsSchema: { type: "object", properties: {}, required: [] },
description: 'List available Lobster workflows',
argsSchema: { type: 'object', properties: {}, required: [] },
sideEffects: [],
},
help() {
return `workflows.list — list available Lobster workflows\n\nUsage:\n workflows.list\n\nNotes:\n - Intended for OpenClaw to discover workflows dynamically.\n`;
return `workflows.list — list available Lobster workflows\n\nUsage:\n workflows.list\n\nNotes:\n - Intended for Clawdbot to discover workflows dynamically.\n`;
},
async run({ input }) {
// Drain input.

View File

@ -1,34 +1,35 @@
import { workflowRegistry } from "../../workflows/registry.js";
import {
runGithubPrMonitorWorkflow,
runGithubPrMonitorNotifyWorkflow,
} from "../../workflows/github_pr_monitor.js";
import { workflowRegistry } from '../../workflows/registry.js';
import { runGithubPrMonitorWorkflow, runGithubPrMonitorNotifyWorkflow } from '../../workflows/github_pr_monitor.js';
import { runOpenclawReleasePostWorkflow, runOpenclawReleaseTweetWorkflow } from '../../workflows/openclaw_release.js';
const runners = {
"github.pr.monitor": runGithubPrMonitorWorkflow,
"github.pr.monitor.notify": runGithubPrMonitorNotifyWorkflow,
'openclaw.release.tweet': runOpenclawReleaseTweetWorkflow,
'openclaw.release.post': runOpenclawReleasePostWorkflow,
'github.pr.monitor': runGithubPrMonitorWorkflow,
'github.pr.monitor.notify': runGithubPrMonitorNotifyWorkflow,
};
// Recipe runners - adapt SDK recipes to workflow runner interface
const recipeRunners = {};
export const workflowsRunCommand = {
name: "workflows.run",
name: 'workflows.run',
meta: {
description: "Run a named Lobster workflow",
description: 'Run a named Lobster workflow',
argsSchema: {
type: "object",
type: 'object',
properties: {
name: { type: "string", description: "Workflow name" },
"args-json": { type: "string", description: "JSON string of workflow args" },
_: { type: "array", items: { type: "string" } },
name: { type: 'string', description: 'Workflow name' },
'args-json': { type: 'string', description: 'JSON string of workflow args' },
_: { type: 'array', items: { type: 'string' } },
},
required: ["name"],
required: ['name'],
},
sideEffects: [],
},
help() {
return `workflows.run — run a named Lobster workflow\n\nUsage:\n workflows.run --name <workflow> [--args-json '{...}']\n\nExample:\n workflows.run --name github.pr.monitor.notify --args-json '{"repo":"openclaw/openclaw","pr":1152}'\n`;
return `workflows.run — run a named Lobster workflow\n\nUsage:\n workflows.run --name <workflow> [--args-json '{...}']\n\nExample:\n workflows.run --name github.pr.monitor.notify --args-json '{"repo":"clawdbot/clawdbot","pr":1152}'\n`;
},
async run({ input, args, ctx }) {
// Drain input.
@ -37,17 +38,17 @@ export const workflowsRunCommand = {
}
const name = args.name ?? args._[0];
if (!name) throw new Error("workflows.run requires --name");
if (!name) throw new Error('workflows.run requires --name');
// Check for recipe-based workflow first
const recipeRunner = recipeRunners[name];
if (recipeRunner) {
let workflowArgs = {};
if (args["args-json"]) {
if (args['args-json']) {
try {
workflowArgs = JSON.parse(String(args["args-json"]));
workflowArgs = JSON.parse(String(args['args-json']));
} catch {
throw new Error("workflows.run --args-json must be valid JSON");
throw new Error('workflows.run --args-json must be valid JSON');
}
}
const result = await recipeRunner({ args: workflowArgs, ctx });
@ -62,11 +63,11 @@ export const workflowsRunCommand = {
if (!runner) throw new Error(`Workflow runner not implemented: ${name}`);
let workflowArgs = {};
if (args["args-json"]) {
if (args['args-json']) {
try {
workflowArgs = JSON.parse(String(args["args-json"]));
workflowArgs = JSON.parse(String(args['args-json']));
} catch {
throw new Error("workflows.run --args-json must be valid JSON");
throw new Error('workflows.run --args-json must be valid JSON');
}
}

View File

@ -1,111 +0,0 @@
export type StepCost = {
stepId: string;
model: string | null;
inputTokens: number;
outputTokens: number;
costUsd: number;
};
export type CostSummary = {
totalInputTokens: number;
totalOutputTokens: number;
estimatedCostUsd: number;
byStep: StepCost[];
};
export type CostLimit = {
max_usd: number;
action?: "warn" | "stop";
};
const DEFAULT_PRICING: Record<string, { input: number; output: number }> = {
"gpt-4o": { input: 2.5, output: 10.0 },
"gpt-4o-mini": { input: 0.15, output: 0.6 },
"gpt-4-turbo": { input: 10.0, output: 30.0 },
"gpt-3.5-turbo": { input: 0.5, output: 1.5 },
"claude-opus-4-20250514": { input: 15.0, output: 75.0 },
"claude-sonnet-4-5-20250514": { input: 3.0, output: 15.0 },
"claude-haiku-3-5": { input: 0.8, output: 4.0 },
"gemini-1.5-pro": { input: 1.25, output: 5.0 },
"gemini-1.5-flash": { input: 0.075, output: 0.3 },
};
function toTokenCount(value: unknown): number {
const parsed = Number(value ?? 0);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Math.floor(parsed);
}
export class CostTracker {
private steps: StepCost[] = [];
private pricing: Record<string, { input: number; output: number }>;
constructor(customPricing?: Record<string, { input: number; output: number }>) {
this.pricing = { ...DEFAULT_PRICING, ...(customPricing ?? {}) };
}
recordUsage(stepId: string, model: string | null, usage: Record<string, unknown>) {
const inputTokens = toTokenCount(
usage.inputTokens ?? usage.input_tokens ?? usage.prompt_tokens,
);
const outputTokens = toTokenCount(
usage.outputTokens ?? usage.output_tokens ?? usage.completion_tokens,
);
const pricing = this.pricing[model ?? ""] ?? { input: 0, output: 0 };
const costUsd = (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
this.steps.push({ stepId, model, inputTokens, outputTokens, costUsd });
}
getSummary(): CostSummary {
let totalInputTokens = 0;
let totalOutputTokens = 0;
let estimatedCostUsd = 0;
for (const step of this.steps) {
totalInputTokens += step.inputTokens;
totalOutputTokens += step.outputTokens;
estimatedCostUsd += step.costUsd;
}
return {
totalInputTokens,
totalOutputTokens,
estimatedCostUsd: Math.round(estimatedCostUsd * 1_000_000) / 1_000_000,
byStep: [...this.steps],
};
}
hasUsage() {
return this.steps.length > 0;
}
checkLimit(limit: CostLimit, stderr?: NodeJS.WritableStream) {
const summary = this.getSummary();
if (summary.estimatedCostUsd <= limit.max_usd) return;
if (limit.action === "stop") {
throw new Error(
`Cost limit exceeded: $${summary.estimatedCostUsd.toFixed(4)} > $${limit.max_usd.toFixed(2)} limit`,
);
}
if (stderr) {
stderr.write(
`[WARN] Cost $${summary.estimatedCostUsd.toFixed(4)} exceeds limit $${limit.max_usd.toFixed(2)}\n`,
);
}
}
static parsePricingFromEnv(
env: Record<string, string | undefined>,
): Record<string, { input: number; output: number }> | undefined {
const raw = env.LOBSTER_LLM_PRICING_JSON;
if (!raw) return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
}

View File

@ -1,101 +0,0 @@
export type FilterFn = (value: unknown, ...args: string[]) => unknown;
const FILTERS = new Map<string, FilterFn>();
FILTERS.set("upper", (v) => String(v ?? "").toUpperCase());
FILTERS.set("lower", (v) => String(v ?? "").toLowerCase());
FILTERS.set("trim", (v) => String(v ?? "").trim());
FILTERS.set("truncate", (v, n) => {
const s = String(v ?? "");
const parsed = parseInt(n ?? "", 10);
const len = Number.isNaN(parsed) ? 80 : parsed;
return s.length > len ? `${s.slice(0, len)}...` : s;
});
FILTERS.set("replace", (v, from, to) => String(v ?? "").replaceAll(from ?? "", to ?? ""));
FILTERS.set("split", (v, sep) => String(v ?? "").split(sep ?? ","));
FILTERS.set("first", (v) => (Array.isArray(v) ? v[0] : v));
FILTERS.set("last", (v) => (Array.isArray(v) ? v[v.length - 1] : v));
FILTERS.set("length", (v) => {
if (Array.isArray(v)) return v.length;
if (typeof v === "string") return v.length;
return 0;
});
FILTERS.set("join", (v, sep) => (Array.isArray(v) ? v.join(sep ?? ", ") : String(v ?? "")));
FILTERS.set("json", (v) => JSON.stringify(v, null, 2));
FILTERS.set("string", (v) => String(v ?? ""));
FILTERS.set("default", (v, def) => (v == null || v === "" ? def : v));
FILTERS.set("round", (v, n) => {
const num = Number(v);
const dec = parseInt(n ?? "", 10) || 0;
return Number.isNaN(num) ? v : Number(num.toFixed(dec));
});
FILTERS.set("date", (v, fmt) => {
const d =
typeof v === "number" || (typeof v === "string" && /^\d+$/.test(v.trim()))
? new Date(Number(v))
: new Date(String(v));
if (Number.isNaN(d.getTime())) return String(v);
if (!fmt) return d.toISOString();
return fmt
.replace("YYYY", String(d.getUTCFullYear()))
.replace("MM", String(d.getUTCMonth() + 1).padStart(2, "0"))
.replace("DD", String(d.getUTCDate()).padStart(2, "0"))
.replace("HH", String(d.getUTCHours()).padStart(2, "0"))
.replace("mm", String(d.getUTCMinutes()).padStart(2, "0"))
.replace("ss", String(d.getUTCSeconds()).padStart(2, "0"));
});
export function getFilter(name: string): FilterFn | undefined {
return FILTERS.get(name);
}
export function parseFilterExpression(expr: string): [string, ...string[]] {
const trimmed = expr.trim();
const parts: string[] = [];
let i = 0;
while (i < trimmed.length) {
while (i < trimmed.length && trimmed[i] === " ") i += 1;
if (i >= trimmed.length) break;
if (trimmed[i] === '"' || trimmed[i] === "'") {
const quote = trimmed[i];
i += 1;
let arg = "";
while (i < trimmed.length && trimmed[i] !== quote) {
if (trimmed[i] === "\\" && i + 1 < trimmed.length) {
arg += trimmed[i + 1];
i += 2;
} else {
arg += trimmed[i];
i += 1;
}
}
if (i < trimmed.length) i += 1;
parts.push(arg);
} else {
let arg = "";
while (i < trimmed.length && trimmed[i] !== " ") {
arg += trimmed[i];
i += 1;
}
parts.push(arg);
}
}
if (parts.length === 0) {
return [trimmed];
}
return parts as [string, ...string[]];
}
export function applyFilters(value: unknown, filterChain: string[]): unknown {
let result = value;
for (const filterExpr of filterChain) {
const [name, ...args] = parseFilterExpression(filterExpr);
const fn = FILTERS.get(name);
if (!fn) throw new Error(`Unknown template filter: ${name}`);
result = fn(result, ...args);
}
return result;
}

View File

@ -1,6 +0,0 @@
export { createDefaultRegistry } from "../commands/registry.js";
export { parsePipeline } from "../parser.js";
export { runPipeline } from "../runtime.js";
export { runWorkflowFile } from "../workflows/file.js";
export { decodeResumeToken } from "../resume.js";
export { runToolRequest, resumeToolRequest, createToolContext } from "./tool_runtime.js";

View File

@ -1,100 +0,0 @@
export type RetryConfig = {
max?: number;
backoff?: "fixed" | "exponential";
delay_ms?: number;
max_delay_ms?: number;
jitter?: boolean;
};
const DEFAULTS = {
max: 1,
backoff: "fixed" as const,
delay_ms: 1000,
max_delay_ms: 30000,
jitter: false,
};
export function resolveRetryConfig(raw: RetryConfig | undefined): Required<RetryConfig> {
if (!raw) return { ...DEFAULTS };
return {
max: raw.max ?? DEFAULTS.max,
backoff: raw.backoff ?? DEFAULTS.backoff,
delay_ms: raw.delay_ms ?? DEFAULTS.delay_ms,
max_delay_ms: raw.max_delay_ms ?? DEFAULTS.max_delay_ms,
jitter: raw.jitter ?? DEFAULTS.jitter,
};
}
function computeDelay(config: Required<RetryConfig>, attempt: number): number {
let delay: number;
if (config.backoff === "exponential") {
delay = Math.min(config.delay_ms * Math.pow(2, attempt), config.max_delay_ms);
} else {
delay = config.delay_ms;
}
if (config.jitter) {
// +/- 10% randomization, clamped to max_delay_ms
const jitterRange = delay * 0.1;
delay += (Math.random() * 2 - 1) * jitterRange;
delay = Math.min(delay, config.max_delay_ms);
}
return Math.max(0, Math.round(delay));
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return new Promise((r) => setTimeout(r, ms));
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
return;
}
let timer: ReturnType<typeof setTimeout>;
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
};
timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Execute `fn` with retries according to the given config.
* Abort errors always propagate immediately (no retry).
* Returns the result of the first successful call, or throws
* the last error after all retries are exhausted.
*/
export async function withRetry<T>(
fn: () => Promise<T>,
config: Required<RetryConfig>,
options?: {
signal?: AbortSignal;
shouldRetry?: (error: any, attempt: number) => boolean;
onRetry?: (attempt: number, error: Error, delayMs: number) => void;
},
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt < config.max; attempt++) {
try {
return await fn();
} catch (err: any) {
// Never retry abort/cancellation errors
if (err?.name === "AbortError" || err?.code === "ABORT_ERR") {
throw err;
}
lastError = err;
if (attempt + 1 < config.max) {
if (options?.shouldRetry && !options.shouldRetry(err, attempt + 1)) {
throw err;
}
const delay = computeDelay(config, attempt);
options?.onRetry?.(attempt + 1, err, delay);
await abortableSleep(delay, options?.signal);
}
}
}
throw lastError;
}

View File

@ -1,383 +0,0 @@
import { Writable } from "node:stream";
import path from "node:path";
import { createDefaultRegistry } from "../commands/registry.js";
import { parsePipeline } from "../parser.js";
import { decodeResumeToken, kindFromStateKey } from "../resume.js";
import { runPipeline } from "../runtime.js";
import { encodeToken } from "../token.js";
import {
deleteStateJson,
deleteApprovalId,
findStateKeyByApprovalId,
cleanupApprovalIndexByStateKey,
} from "../state/store.js";
import { WorkflowResumeArgumentError, runWorkflowFile } from "../workflows/file.js";
import {
finalizePipelineToolRun,
loadPipelineResumeState,
validatePipelineInputResponse,
} from "../pipeline_resume_state.js";
type ToolRunContext = {
cwd?: string;
env?: Record<string, string | undefined>;
mode?: "tool" | "human" | "sdk";
stdin?: NodeJS.ReadableStream;
stdout?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
signal?: AbortSignal;
registry?: any;
llmAdapters?: Record<string, any>;
};
type ToolEnvelope = {
protocolVersion: 1;
ok: boolean;
status?: "ok" | "needs_approval" | "needs_input" | "cancelled";
output?: unknown[];
requiresApproval?: {
type?: "approval_request";
prompt: string;
items: unknown[];
preview?: string;
resumeToken?: string;
approvalId?: string;
} | null;
requiresInput?: {
type?: "input_request";
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
resumeToken?: string;
} | null;
error?: {
type: string;
message: string;
};
};
export async function runToolRequest({
pipeline,
filePath,
args,
ctx = {},
}: {
pipeline?: string;
filePath?: string;
args?: Record<string, unknown>;
ctx?: ToolRunContext;
}): Promise<ToolEnvelope> {
const runtime = createToolContext(ctx);
const hasPipeline = typeof pipeline === "string" && pipeline.trim().length > 0;
const hasFile = typeof filePath === "string" && filePath.trim().length > 0;
if (!hasPipeline && !hasFile) {
return errorEnvelope("parse_error", "run requires either pipeline or filePath");
}
if (hasPipeline && hasFile) {
return errorEnvelope("parse_error", "run accepts either pipeline or filePath, not both");
}
if (hasFile) {
let resolvedFilePath: string;
try {
resolvedFilePath = await resolveWorkflowFile(filePath!, runtime.cwd);
} catch (err: any) {
return errorEnvelope("parse_error", err?.message ?? String(err));
}
try {
const output = await runWorkflowFile({
filePath: resolvedFilePath,
args,
ctx: runtime,
});
if (output.status === "needs_approval") {
return okEnvelope("needs_approval", [], output.requiresApproval ?? null, null);
}
if (output.status === "needs_input") {
return okEnvelope("needs_input", [], null, output.requiresInput ?? null);
}
if (output.status === "cancelled") {
return okEnvelope("cancelled", [], null, null);
}
return okEnvelope("ok", output.output, null, null);
} catch (err: any) {
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
}
let parsed;
try {
parsed = parsePipeline(String(pipeline));
} catch (err: any) {
return errorEnvelope("parse_error", err?.message ?? String(err));
}
try {
const output = await runPipeline({
pipeline: parsed,
registry: runtime.registry,
input: [],
stdin: runtime.stdin,
stdout: runtime.stdout,
stderr: runtime.stderr,
env: runtime.env,
mode: "tool",
cwd: runtime.cwd,
llmAdapters: runtime.llmAdapters,
signal: runtime.signal,
});
const finalized = await finalizePipelineToolRun({
env: runtime.env,
pipeline: parsed,
output,
});
return okEnvelope(
finalized.status,
finalized.output,
finalized.requiresApproval,
finalized.requiresInput,
);
} catch (err: any) {
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
}
export async function resumeToolRequest({
token,
approvalId,
approved,
response,
cancel,
ctx = {},
}: {
token?: string;
approvalId?: string;
approved?: boolean;
response?: unknown;
cancel?: boolean;
ctx?: ToolRunContext;
}): Promise<ToolEnvelope> {
const runtime = createToolContext(ctx);
let payload: any;
let resolvedApprovalId = approvalId ?? null;
try {
// Resolve short approval ID to token if provided
let resolvedToken: string;
if (approvalId) {
const stateKey = await findStateKeyByApprovalId({ env: runtime.env, approvalId });
if (!stateKey) {
return errorEnvelope("parse_error", `Approval ID "${approvalId}" not found or expired`);
}
const kind = kindFromStateKey(stateKey);
resolvedToken = encodeToken({
protocolVersion: 1,
v: 1,
kind,
stateKey,
});
} else if (token) {
resolvedToken = token;
} else {
return errorEnvelope("parse_error", "resume requires token or approvalId");
}
payload = decodeResumeToken(resolvedToken);
} catch (err: any) {
return errorEnvelope("parse_error", err?.message ?? String(err));
}
// Helper: clean up approval ID index after successful use
const cleanupIndex = async () => {
if (resolvedApprovalId) {
await deleteApprovalId({ env: runtime.env, approvalId: resolvedApprovalId });
} else if (payload?.stateKey) {
await cleanupApprovalIndexByStateKey({ env: runtime.env, stateKey: payload.stateKey });
}
};
if (cancel === true) {
await cleanupIndex();
if (payload.kind === "workflow-file" && payload.stateKey) {
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
}
if (payload.kind === "pipeline-resume" && payload.stateKey) {
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
}
return okEnvelope("cancelled", [], null, null);
}
if (payload.kind === "workflow-file") {
try {
const output = await runWorkflowFile({
filePath: payload.filePath,
ctx: runtime,
resume: payload,
approved,
response,
cancel,
});
if (output.status === "needs_approval") {
// Don't clean up index — next gate will issue a new approvalId
return okEnvelope("needs_approval", [], output.requiresApproval ?? null, null);
}
if (output.status === "needs_input") {
return okEnvelope("needs_input", [], null, output.requiresInput ?? null);
}
await cleanupIndex();
if (output.status === "cancelled") {
return okEnvelope("cancelled", [], null, null);
}
return okEnvelope("ok", output.output, null, null);
} catch (err: any) {
if (err instanceof WorkflowResumeArgumentError) {
return errorEnvelope("parse_error", err.message);
}
// Don't clean up index on error — allow retry by --id
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
}
let resumeState;
try {
resumeState = await loadPipelineResumeState(runtime.env, payload.stateKey);
} catch (err: any) {
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
if (resumeState.haltType === "input_request") {
if (approved !== undefined) {
return errorEnvelope("parse_error", "pipeline input resumes require response");
}
if (response === undefined) {
return errorEnvelope("parse_error", "pipeline input resumes require response");
}
try {
validatePipelineInputResponse(resumeState.inputSchema, response);
} catch (err: any) {
return errorEnvelope("parse_error", err?.message ?? String(err));
}
} else {
if (response !== undefined) {
return errorEnvelope(
"parse_error",
"approval resumes require approved=true|false, not response",
);
}
if (approved !== true) {
await cleanupIndex();
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
return okEnvelope("cancelled", [], null, null);
}
}
const remaining = resumeState.pipeline.slice(resumeState.resumeAtIndex);
const input = streamFromItems(
resumeState.haltType === "input_request" ? [response] : resumeState.items,
);
try {
const output = await runPipeline({
pipeline: remaining,
registry: runtime.registry,
stdin: runtime.stdin,
stdout: runtime.stdout,
stderr: runtime.stderr,
env: runtime.env,
mode: "tool",
cwd: runtime.cwd,
llmAdapters: runtime.llmAdapters,
signal: runtime.signal,
input,
});
await cleanupIndex();
const finalized = await finalizePipelineToolRun({
env: runtime.env,
pipeline: remaining,
output,
previousStateKey: payload.stateKey,
});
return okEnvelope(
finalized.status,
finalized.output,
finalized.requiresApproval,
finalized.requiresInput,
);
} catch (err: any) {
// Don't clean up index on error — allow retry by --id
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
}
export function createToolContext(ctx: ToolRunContext = {}) {
return {
cwd: ctx.cwd ?? process.cwd(),
env: { ...process.env, ...ctx.env },
mode: "tool" as const,
stdin: ctx.stdin ?? process.stdin,
stdout: ctx.stdout ?? createCaptureStream(),
stderr: ctx.stderr ?? createCaptureStream(),
signal: ctx.signal,
registry: ctx.registry ?? createDefaultRegistry(),
llmAdapters: ctx.llmAdapters,
};
}
export function createCaptureStream() {
return new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
}
function okEnvelope(
status: "ok" | "needs_approval" | "needs_input" | "cancelled",
output: unknown[],
requiresApproval: ToolEnvelope["requiresApproval"],
requiresInput: ToolEnvelope["requiresInput"],
) {
return {
protocolVersion: 1 as const,
ok: true,
status,
output,
requiresApproval,
requiresInput,
};
}
function errorEnvelope(type: string, message: string): ToolEnvelope {
return {
protocolVersion: 1,
ok: false,
error: { type, message },
};
}
function streamFromItems(items: unknown[]) {
return (async function* () {
for (const item of items) {
yield item;
}
})();
}
async function resolveWorkflowFile(candidate: string, cwd: string) {
const { stat } = await import("node:fs/promises");
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
const fileStat = await stat(resolved);
if (!fileStat.isFile()) throw new Error("Workflow path is not a file");
const ext = path.extname(resolved).toLowerCase();
if (![".lobster", ".yaml", ".yml", ".json"].includes(ext)) {
throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json");
}
return resolved;
}

View File

@ -1,17 +1,17 @@
function isWhitespace(ch) {
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
}
function splitPipes(input) {
const parts = [];
let current = "";
let current = '';
let quote = null;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (quote) {
if (ch === "\\") {
if (ch === '\\') {
const next = input[i + 1];
if (next) {
current += ch + next;
@ -32,70 +32,46 @@ function splitPipes(input) {
continue;
}
if (ch === "|") {
if (ch === '|') {
parts.push(current.trim());
current = "";
current = '';
continue;
}
current += ch;
}
if (quote) throw new Error("Unclosed quote");
if (quote) throw new Error('Unclosed quote');
if (current.trim().length > 0) parts.push(current.trim());
return parts;
}
function tokenizeCommand(input) {
const tokens = [];
let current = "";
let current = '';
let quote = null;
const push = () => {
if (current.length > 0) tokens.push(current);
current = "";
current = '';
};
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (quote) {
if (quote === "'") {
if (ch === "\\" && input[i + 1] === quote) {
current += quote;
i++;
continue;
}
if (ch === quote) {
quote = null;
continue;
}
current += ch;
continue;
}
// Double-quoted mode: preserve unknown escapes (\n, \t, etc) while
// unescaping only shell-like quote/backslash escapes.
if (ch === "\\") {
if (ch === '\\') {
const next = input[i + 1];
if (next === '"' || next === "\\" || next === "$" || next === "`") {
if (next) {
current += next;
i++;
continue;
}
if (next === "\n") {
i++;
continue;
}
current += ch;
continue;
}
if (ch === quote) {
quote = null;
continue;
}
current += ch;
continue;
}
@ -113,7 +89,7 @@ function tokenizeCommand(input) {
current += ch;
}
if (quote) throw new Error("Unclosed quote");
if (quote) throw new Error('Unclosed quote');
push();
return tokens;
}
@ -124,8 +100,8 @@ function parseArgs(tokens) {
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i];
if (tok.startsWith("--")) {
const eq = tok.indexOf("=");
if (tok.startsWith('--')) {
const eq = tok.indexOf('=');
if (eq !== -1) {
const key = tok.slice(2, eq);
const value = tok.slice(eq + 1);
@ -135,7 +111,7 @@ function parseArgs(tokens) {
const key = tok.slice(2);
const next = tokens[i + 1];
if (!next || next.startsWith("--")) {
if (!next || next.startsWith('--')) {
args[key] = true;
continue;
}
@ -152,11 +128,11 @@ function parseArgs(tokens) {
export function parsePipeline(input) {
const stages = splitPipes(input);
if (stages.length === 0) throw new Error("Empty pipeline");
if (stages.length === 0) throw new Error('Empty pipeline');
return stages.map((stage) => {
const tokens = tokenizeCommand(stage);
if (tokens.length === 0) throw new Error("Empty command stage");
if (tokens.length === 0) throw new Error('Empty command stage');
const name = tokens[0];
const args = parseArgs(tokens.slice(1));
return { name, args, raw: stage };

View File

@ -1,229 +0,0 @@
import { randomUUID } from "node:crypto";
import { encodeToken } from "./token.js";
import {
cleanupApprovalIndexByStateKey,
createApprovalIndex,
deleteStateJson,
readStateJson,
writeStateJson,
} from "./state/store.js";
import { sharedAjv } from "./validation.js";
export type PipelineResumeState = {
pipeline: Array<{ name: string; args: Record<string, unknown>; raw: string }>;
resumeAtIndex: number;
items: unknown[];
haltType?: "approval_request" | "input_request";
inputSchema?: unknown;
prompt?: string;
createdAt: string;
};
export type PipelineApprovalRequest = {
type: "approval_request";
prompt: string;
items: unknown[];
preview?: string;
};
export type PipelineInputRequest = {
type: "input_request";
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
items?: unknown[];
};
export type PipelineRunOutput = {
items: unknown[];
halted?: boolean;
haltedAt?: { index: number } | null;
};
export type PipelineToolRunResolution =
| {
status: "needs_approval";
output: [];
requiresApproval: {
type: "approval_request";
prompt: string;
items: unknown[];
preview?: string;
resumeToken: string;
approvalId: string;
};
requiresInput: null;
}
| {
status: "needs_input";
output: [];
requiresApproval: null;
requiresInput: {
type: "input_request";
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
resumeToken: string;
};
}
| {
status: "ok";
output: unknown[];
requiresApproval: null;
requiresInput: null;
};
export function extractPipelineHalt(output: { halted?: boolean; items: unknown[] }) {
const halted =
output.halted && output.items.length === 1
? (output.items[0] as Record<string, unknown>)
: null;
const approval =
halted?.type === "approval_request" ? (halted as unknown as PipelineApprovalRequest) : null;
const inputRequest =
halted?.type === "input_request" ? (halted as unknown as PipelineInputRequest) : null;
return { approval, inputRequest };
}
export async function finalizePipelineToolRun(params: {
env: Record<string, string | undefined>;
pipeline: PipelineResumeState["pipeline"];
output: PipelineRunOutput;
previousStateKey?: string;
}): Promise<PipelineToolRunResolution> {
const { approval, inputRequest } = extractPipelineHalt(params.output);
if (approval) {
const nextStateKey = await savePipelineResumeState(params.env, {
pipeline: params.pipeline,
resumeAtIndex: (params.output.haltedAt?.index ?? -1) + 1,
items: approval.items,
haltType: "approval_request",
prompt: approval.prompt,
createdAt: new Date().toISOString(),
});
if (params.previousStateKey) {
await cleanupApprovalIndexByStateKey({ env: params.env, stateKey: params.previousStateKey });
await deleteStateJson({ env: params.env, key: params.previousStateKey });
}
let approvalId: string;
try {
approvalId = await createApprovalIndex({ env: params.env, stateKey: nextStateKey });
} catch (err) {
await deleteStateJson({ env: params.env, key: nextStateKey }).catch(() => {});
throw err;
}
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
kind: "pipeline-resume",
stateKey: nextStateKey,
});
return {
status: "needs_approval",
output: [],
requiresApproval: {
...approval,
resumeToken,
approvalId,
},
requiresInput: null,
};
}
if (inputRequest) {
const nextStateKey = await savePipelineResumeState(params.env, {
pipeline: params.pipeline,
resumeAtIndex: (params.output.haltedAt?.index ?? -1) + 1,
items: [],
haltType: "input_request",
inputSchema: inputRequest.responseSchema,
prompt: inputRequest.prompt,
createdAt: new Date().toISOString(),
});
if (params.previousStateKey) {
await cleanupApprovalIndexByStateKey({ env: params.env, stateKey: params.previousStateKey });
await deleteStateJson({ env: params.env, key: params.previousStateKey });
}
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
kind: "pipeline-resume",
stateKey: nextStateKey,
});
return {
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: {
type: "input_request",
prompt: inputRequest.prompt,
responseSchema: inputRequest.responseSchema,
...(inputRequest.defaults !== undefined ? { defaults: inputRequest.defaults } : null),
...(inputRequest.subject !== undefined ? { subject: inputRequest.subject } : null),
resumeToken,
},
};
}
if (params.previousStateKey) {
await cleanupApprovalIndexByStateKey({ env: params.env, stateKey: params.previousStateKey });
await deleteStateJson({ env: params.env, key: params.previousStateKey });
}
return {
status: "ok",
output: params.output.items,
requiresApproval: null,
requiresInput: null,
};
}
export async function savePipelineResumeState(
env: Record<string, string | undefined>,
state: PipelineResumeState,
) {
const stateKey = `pipeline_resume_${randomUUID()}`;
await writeStateJson({ env, key: stateKey, value: state });
return stateKey;
}
export async function loadPipelineResumeState(
env: Record<string, string | undefined>,
stateKey: string,
) {
const stored = await readStateJson({ env, key: stateKey });
if (!stored || typeof stored !== "object") {
throw new Error("Pipeline resume state not found");
}
const data = stored as Partial<PipelineResumeState>;
if (!Array.isArray(data.pipeline)) throw new Error("Invalid pipeline resume state");
if (typeof data.resumeAtIndex !== "number") throw new Error("Invalid pipeline resume state");
if (!Array.isArray(data.items)) throw new Error("Invalid pipeline resume state");
if (
data.haltType !== undefined &&
!["approval_request", "input_request"].includes(data.haltType)
) {
throw new Error("Invalid pipeline resume state");
}
return data as PipelineResumeState;
}
export function validatePipelineInputResponse(schema: unknown, response: unknown) {
if (schema === undefined) {
throw new Error("pipeline input response schema is missing");
}
let validator;
try {
validator = sharedAjv.compile(schema as any);
} catch {
throw new Error("pipeline input response schema is invalid");
}
const ok = validator(response);
if (ok) return;
const first = validator.errors?.[0];
const pathValue = first?.instancePath || "/";
const reason = first?.message ? ` ${first.message}` : "";
throw new Error(`pipeline input response failed schema validation at ${pathValue}:${reason}`);
}

View File

@ -1,54 +0,0 @@
export function readLineFromStream(stream: NodeJS.ReadableStream, opts?: { timeoutMs?: number }) {
const timeoutMs = Number(opts?.timeoutMs ?? 0);
return new Promise<string>((resolve, reject) => {
let settled = false;
let buf = "";
let timer: NodeJS.Timeout | null = null;
const cleanup = () => {
stream.off("data", onData);
stream.off("end", onEnd);
stream.off("close", onClose);
stream.off("error", onError);
if (timer) clearTimeout(timer);
};
const finish = (value: string) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const fail = (err: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onData = (chunk: Buffer | string) => {
buf += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
const idx = buf.indexOf("\n");
if (idx !== -1) {
finish(buf.slice(0, idx));
}
};
const onEnd = () => finish(buf);
const onClose = () => finish(buf);
const onError = (err: Error) => fail(err);
if (timeoutMs > 0) {
timer = setTimeout(() => {
fail(new Error(`Timed out waiting for input (${timeoutMs}ms)`));
}, timeoutMs);
}
stream.on("data", onData);
stream.on("end", onEnd);
stream.on("close", onClose);
stream.on("error", onError);
});
}

View File

@ -7,12 +7,12 @@
* const result = await prMonitor({ repo: 'owner/repo', pr: 123 }).run();
*/
export { prMonitor, prMonitorNotify } from "./pr-monitor.js";
export { ghPrView } from "./stages/pr-view.js";
export { prMonitor, prMonitorNotify } from './pr-monitor.js';
export { ghPrView } from './stages/pr-view.js';
// Register recipes
import { registerRecipe } from "../registry.js";
import { prMonitor, prMonitorNotify } from "./pr-monitor.js";
import { registerRecipe } from '../registry.js';
import { prMonitor, prMonitorNotify } from './pr-monitor.js';
registerRecipe(prMonitor);
registerRecipe(prMonitorNotify);

View File

@ -11,9 +11,9 @@
* const notify = await prMonitorNotify({ repo: 'owner/repo', pr: 123 }).run();
*/
import { Lobster } from "../../sdk/index.js";
import { diffLast } from "../../sdk/primitives/diff.js";
import { ghPrView } from "./stages/pr-view.js";
import { Lobster } from '../../sdk/index.js';
import { diffLast } from '../../sdk/primitives/diff.js';
import { ghPrView } from './stages/pr-view.js';
/**
* Pick a subset of PR fields for comparison
@ -21,7 +21,7 @@ import { ghPrView } from "./stages/pr-view.js";
* @returns {Object|null}
*/
function pickSubset(snapshot) {
if (!snapshot || typeof snapshot !== "object") return null;
if (!snapshot || typeof snapshot !== 'object') return null;
return {
number: snapshot.number,
title: snapshot.title,
@ -73,10 +73,10 @@ function buildChangeSummary(before, after) {
* @returns {string}
*/
function formatChangeMessage({ repo, pr, changedFields, prInfo }) {
const fields = changedFields.length ? ` (${changedFields.join(", ")})` : "";
const title = prInfo?.title ? `: ${prInfo.title}` : "";
const url = prInfo?.url ? ` ${prInfo.url}` : "";
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, " ").trim();
const fields = changedFields.length ? ` (${changedFields.join(', ')})` : '';
const title = prInfo?.title ? `: ${prInfo.title}` : '';
const url = prInfo?.url ? ` ${prInfo.url}` : '';
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, ' ').trim();
}
/**
@ -94,8 +94,8 @@ export function prMonitor(options) {
const { repo, pr, changesOnly = false, summaryOnly = false } = options;
const key = options.key ?? `github.pr:${repo}#${pr}`;
if (!repo) throw new Error("prMonitor requires repo");
if (!pr) throw new Error("prMonitor requires pr");
if (!repo) throw new Error('prMonitor requires repo');
if (!pr) throw new Error('prMonitor requires pr');
const workflow = new Lobster()
.pipe(ghPrView({ repo, pr }))
@ -108,62 +108,56 @@ export function prMonitor(options) {
// If changesOnly and no change, suppress output
if (changesOnly && !changed) {
return [
{
kind: "github.pr.monitor",
repo,
pr: Number(pr),
key,
changed: false,
suppressed: true,
},
];
return [{
kind: 'github.pr.monitor',
repo,
pr: Number(pr),
key,
changed: false,
suppressed: true,
}];
}
const summary = buildChangeSummary(before, current);
if (summaryOnly) {
return [
{
kind: "github.pr.monitor",
repo,
pr: Number(pr),
key,
changed,
summary,
prInfo: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
updatedAt: current.updatedAt,
},
},
];
}
return [
{
kind: "github.pr.monitor",
return [{
kind: 'github.pr.monitor',
repo,
pr: Number(pr),
key,
changed,
summary,
prSnapshot: current,
},
];
prInfo: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
updatedAt: current.updatedAt,
},
}];
}
return [{
kind: 'github.pr.monitor',
repo,
pr: Number(pr),
key,
changed,
summary,
prSnapshot: current,
}];
})
.meta({
name: "github.pr.monitor",
description: "Monitor PR state and detect changes",
requires: ["gh"],
name: 'github.pr.monitor',
description: 'Monitor PR state and detect changes',
requires: ['gh'],
args: {
repo: { type: "string", required: true, description: "Repository (owner/repo)" },
pr: { type: "number", required: true, description: "PR number" },
key: { type: "string", description: "State key override" },
changesOnly: { type: "boolean", default: false, description: "Only output when changed" },
summaryOnly: { type: "boolean", default: false, description: "Return compact summary" },
repo: { type: 'string', required: true, description: 'Repository (owner/repo)' },
pr: { type: 'number', required: true, description: 'PR number' },
key: { type: 'string', description: 'State key override' },
changesOnly: { type: 'boolean', default: false, description: 'Only output when changed' },
summaryOnly: { type: 'boolean', default: false, description: 'Return compact summary' },
},
});
@ -172,15 +166,15 @@ export function prMonitor(options) {
// Attach metadata
prMonitor.meta = {
name: "github.pr.monitor",
description: "Monitor PR state and detect changes",
requires: ["gh"],
name: 'github.pr.monitor',
description: 'Monitor PR state and detect changes',
requires: ['gh'],
args: {
repo: { type: "string", required: true },
pr: { type: "number", required: true },
key: { type: "string" },
changesOnly: { type: "boolean", default: false },
summaryOnly: { type: "boolean", default: false },
repo: { type: 'string', required: true },
pr: { type: 'number', required: true },
key: { type: 'string' },
changesOnly: { type: 'boolean', default: false },
summaryOnly: { type: 'boolean', default: false },
},
};
@ -198,8 +192,8 @@ export function prMonitorNotify(options) {
const { repo, pr } = options;
const key = options.key ?? `github.pr:${repo}#${pr}`;
if (!repo) throw new Error("prMonitorNotify requires repo");
if (!pr) throw new Error("prMonitorNotify requires pr");
if (!repo) throw new Error('prMonitorNotify requires repo');
if (!pr) throw new Error('prMonitorNotify requires pr');
const workflow = new Lobster()
.pipe(ghPrView({ repo, pr }))
@ -208,7 +202,7 @@ export function prMonitorNotify(options) {
const diffResult = results[0];
if (diffResult.suppressed) {
return [{ kind: "github.pr.monitor.notify", suppressed: true }];
return [{ kind: 'github.pr.monitor.notify', suppressed: true }];
}
const current = diffResult.after;
@ -222,31 +216,29 @@ export function prMonitorNotify(options) {
prInfo: current,
});
return [
{
kind: "github.pr.monitor.notify",
changed: true,
repo,
pr: Number(pr),
message,
prInfo: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
},
summary,
return [{
kind: 'github.pr.monitor.notify',
changed: true,
repo,
pr: Number(pr),
message,
prInfo: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
},
];
summary,
}];
})
.meta({
name: "github.pr.monitor.notify",
description: "Emit a notification message when PR changes",
requires: ["gh"],
name: 'github.pr.monitor.notify',
description: 'Emit a notification message when PR changes',
requires: ['gh'],
args: {
repo: { type: "string", required: true },
pr: { type: "number", required: true },
key: { type: "string" },
repo: { type: 'string', required: true },
pr: { type: 'number', required: true },
key: { type: 'string' },
},
});
@ -255,12 +247,12 @@ export function prMonitorNotify(options) {
// Attach metadata
prMonitorNotify.meta = {
name: "github.pr.monitor.notify",
description: "Emit a notification message when PR changes",
requires: ["gh"],
name: 'github.pr.monitor.notify',
description: 'Emit a notification message when PR changes',
requires: ['gh'],
args: {
repo: { type: "string", required: true },
pr: { type: "number", required: true },
key: { type: "string" },
repo: { type: 'string', required: true },
pr: { type: 'number', required: true },
key: { type: 'string' },
},
};

View File

@ -10,7 +10,7 @@
* .pipe(pr => console.log(pr.state));
*/
import { spawn } from "node:child_process";
import { spawn } from 'node:child_process';
/**
* Run gh command
@ -20,34 +20,30 @@ import { spawn } from "node:child_process";
*/
function runGh(argv, { env, cwd }) {
return new Promise<any>((resolve, reject) => {
const child = spawn("gh", argv, {
const child = spawn('gh', argv, {
env,
cwd,
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = "";
let stderr = "";
let stdout = '';
let stderr = '';
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on("data", (d) => {
stdout += d;
});
child.stderr.on("data", (d) => {
stderr += d;
});
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on("error", (err: any) => {
if (err?.code === "ENOENT") {
reject(new Error("gh not found on PATH (install GitHub CLI)"));
child.on('error', (err: any) => {
if (err?.code === 'ENOENT') {
reject(new Error('gh not found on PATH (install GitHub CLI)'));
return;
}
reject(err);
});
child.on("close", (code) => {
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
@ -69,24 +65,16 @@ function runGh(argv, { env, cwd }) {
export function ghPrView(options) {
const { repo, pr } = options;
const fields = options.fields ?? [
"number",
"title",
"url",
"state",
"isDraft",
"mergeable",
"reviewDecision",
"author",
"baseRefName",
"headRefName",
"updatedAt",
'number', 'title', 'url', 'state', 'isDraft',
'mergeable', 'reviewDecision', 'author',
'baseRefName', 'headRefName', 'updatedAt',
];
if (!repo) throw new Error("ghPrView requires repo");
if (!pr) throw new Error("ghPrView requires pr");
if (!repo) throw new Error('ghPrView requires repo');
if (!pr) throw new Error('ghPrView requires pr');
return {
type: "github.pr.view",
type: 'github.pr.view',
repo,
pr,
@ -96,7 +84,12 @@ export function ghPrView(options) {
// no-op
}
const argv = ["pr", "view", String(pr), "--repo", String(repo), "--json", fields.join(",")];
const argv = [
'pr', 'view',
String(pr),
'--repo', String(repo),
'--json', fields.join(','),
];
const { stdout } = (await runGh(argv, { env: ctx.env, cwd: process.cwd() })) as any;
@ -104,7 +97,7 @@ export function ghPrView(options) {
try {
parsed = JSON.parse(stdout.trim());
} catch {
throw new Error("gh returned non-JSON output");
throw new Error('gh returned non-JSON output');
}
return {

View File

@ -1,7 +1,7 @@
/**
* Recipe entrypoints.
*
* Core Lobster intentionally keeps only OpenClaw-first recipes here.
* Core Lobster intentionally keeps only Clawdbot-first recipes here.
*/
export * from "./github/index.js";

View File

@ -2,10 +2,10 @@ export function createJsonRenderer(stdout) {
return {
json(items) {
stdout.write(JSON.stringify(items, null, 2));
stdout.write("\n");
stdout.write('\n');
},
lines(lines) {
for (const line of lines) stdout.write(String(line) + "\n");
for (const line of lines) stdout.write(String(line) + '\n');
},
};
}

View File

@ -1,186 +1,55 @@
import { decodeToken, encodeToken } from "./token.js";
import { decodeWorkflowResumePayload } from "./workflows/file.js";
import { findStateKeyByApprovalId } from "./state/store.js";
/**
* Determine the resume payload kind from a state key prefix.
* State keys use naming conventions: pipeline_resume_<uuid> or workflow_resume_<uuid>.
*/
export function kindFromStateKey(stateKey: string): "pipeline-resume" | "workflow-file" {
if (stateKey.startsWith("pipeline_resume_")) return "pipeline-resume";
if (stateKey.startsWith("workflow_resume_")) return "workflow-file";
// Fallback for unknown prefixes — workflow-file is the original behavior
return "workflow-file";
}
export type PipelineResumePayload = {
protocolVersion: 1;
v: 1;
kind: "pipeline-resume";
stateKey: string;
};
import { decodeToken } from './token.js';
import { decodeWorkflowResumePayload } from './workflows/file.js';
export function parseResumeArgs(argv) {
const args = { decision: null, token: null, approvalId: null, responseJson: null, cancel: false };
const args = { decision: null, token: null };
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === "--token") {
if (tok === '--token') {
args.token = argv[i + 1];
i++;
continue;
}
if (tok.startsWith("--token=")) {
args.token = tok.slice("--token=".length);
if (tok.startsWith('--token=')) {
args.token = tok.slice('--token='.length);
continue;
}
if (tok === "--id") {
args.approvalId = argv[i + 1];
i++;
continue;
}
if (tok.startsWith("--id=")) {
args.approvalId = tok.slice("--id=".length);
continue;
}
if (tok === "--response-json") {
args.responseJson = argv[i + 1];
i++;
continue;
}
if (tok.startsWith("--response-json=")) {
args.responseJson = tok.slice("--response-json=".length);
continue;
}
if (tok === "--cancel") {
const next = argv[i + 1];
if (typeof next === "string" && !next.startsWith("--")) {
const parsed = parseBooleanArg(next);
if (parsed === null) {
throw new Error("resume --cancel must be true or false");
}
args.cancel = parsed;
i++;
continue;
}
args.cancel = true;
continue;
}
if (tok.startsWith("--cancel=")) {
const parsed = parseBooleanArg(tok.slice("--cancel=".length));
if (parsed === null) throw new Error("resume --cancel must be true or false");
args.cancel = parsed;
continue;
}
if (tok === "--approve" || tok === "--decision") {
if (tok === '--approve' || tok === '--decision') {
args.decision = argv[i + 1];
i++;
continue;
}
if (tok.startsWith("--approve=")) {
args.decision = tok.slice("--approve=".length);
if (tok.startsWith('--approve=')) {
args.decision = tok.slice('--approve='.length);
continue;
}
if (tok.startsWith("--decision=")) {
args.decision = tok.slice("--decision=".length);
if (tok.startsWith('--decision=')) {
args.decision = tok.slice('--decision='.length);
continue;
}
}
if (!args.token && !args.approvalId) throw new Error("resume requires --token or --id");
const intentCount =
Number(Boolean(args.decision)) + Number(args.responseJson !== null) + Number(args.cancel);
if (intentCount > 1) {
throw new Error("resume accepts only one of --approve, --response-json, or --cancel");
}
if (intentCount === 0) {
throw new Error("resume requires --approve yes|no, --response-json, or --cancel");
}
if (args.cancel) {
return {
token: args.token ? String(args.token) : null,
approvalId: args.approvalId ? String(args.approvalId) : null,
cancel: true,
};
}
if (args.responseJson !== null) {
try {
return {
token: args.token ? String(args.token) : null,
approvalId: args.approvalId ? String(args.approvalId) : null,
response: JSON.parse(String(args.responseJson)),
};
} catch {
throw new Error("resume --response-json must be valid JSON");
}
}
if (!args.token) throw new Error('resume requires --token');
if (!args.decision) throw new Error('resume requires --approve yes|no');
const decision = String(args.decision).toLowerCase();
if (!["yes", "y", "no", "n"].includes(decision))
throw new Error("resume --approve must be yes or no");
return {
token: args.token ? String(args.token) : null,
approvalId: args.approvalId ? String(args.approvalId) : null,
approved: decision === "yes" || decision === "y",
};
}
if (!['yes', 'y', 'no', 'n'].includes(decision)) throw new Error('resume --approve must be yes or no');
function parseBooleanArg(value: string): boolean | null {
const raw = String(value ?? "")
.trim()
.toLowerCase();
if (["1", "true", "yes", "y"].includes(raw)) return true;
if (["0", "false", "no", "n"].includes(raw)) return false;
return null;
}
/**
* Resolve an approval ID to a resume token by looking up the state key.
* Detects the kind (workflow-file vs pipeline-resume) from the state key prefix.
*/
export async function resolveApprovalId(
approvalId: string,
env: Record<string, string | undefined>,
): Promise<string> {
const stateKey = await findStateKeyByApprovalId({ env, approvalId });
if (!stateKey) {
throw new Error(`Approval ID "${approvalId}" not found or expired`);
}
const kind = kindFromStateKey(stateKey);
return encodeToken({
protocolVersion: 1,
v: 1,
kind,
stateKey,
});
return { token: String(args.token), approved: decision === 'yes' || decision === 'y' };
}
export function decodeResumeToken(token) {
const payload = decodeToken(token);
if (!payload || typeof payload !== "object") throw new Error("Invalid token");
if (payload.protocolVersion !== 1) throw new Error("Unsupported protocol version");
if (payload.v !== 1) throw new Error("Unsupported token version");
if (!payload || typeof payload !== 'object') throw new Error('Invalid token');
if (payload.protocolVersion !== 1) throw new Error('Unsupported protocol version');
if (payload.v !== 1) throw new Error('Unsupported token version');
const workflowPayload = decodeWorkflowResumePayload(payload);
if (workflowPayload) return workflowPayload;
const pipelinePayload = decodePipelineResumePayload(payload);
if (pipelinePayload) return pipelinePayload;
throw new Error("Invalid token");
}
function decodePipelineResumePayload(payload: unknown): PipelineResumePayload | null {
if (!payload || typeof payload !== "object") return null;
const data = payload as Partial<PipelineResumePayload>;
if (data.kind !== "pipeline-resume") return null;
if (data.protocolVersion !== 1 || data.v !== 1) throw new Error("Unsupported token version");
if (!data.stateKey || typeof data.stateKey !== "string") throw new Error("Invalid token");
return {
protocolVersion: 1,
v: 1,
kind: "pipeline-resume",
stateKey: data.stateKey,
};
if (!Array.isArray(payload.pipeline)) throw new Error('Invalid token');
if (typeof payload.resumeAtIndex !== 'number') throw new Error('Invalid token');
if (!Array.isArray(payload.items)) throw new Error('Invalid token');
return payload;
}

View File

@ -1,36 +1,6 @@
import { createJsonRenderer } from "./renderers/json.js";
export async function runPipeline({
pipeline,
registry,
stdin,
stdout,
stderr,
env,
mode = "human",
input,
cwd = undefined,
llmAdapters = undefined,
signal = undefined,
dryRun = false,
}: {
pipeline: any[];
registry: any;
stdin: any;
stdout: any;
stderr: any;
env: any;
mode?: string;
input?: any;
cwd?: string | undefined;
llmAdapters?: Record<string, any> | undefined;
signal?: AbortSignal | undefined;
dryRun?: boolean;
}) {
if (dryRun) {
return dryRunPipeline({ pipeline, registry, stderr });
}
import { createJsonRenderer } from './renderers/json.js';
export async function runPipeline({ pipeline, registry, stdin, stdout, stderr, env, mode = 'human', input }) {
let stream = input ?? emptyStream();
let rendered = false;
let halted = false;
@ -43,9 +13,6 @@ export async function runPipeline({
env,
registry,
mode,
cwd,
llmAdapters,
signal,
render: createJsonRenderer(stdout),
};
@ -78,48 +45,4 @@ export async function runPipeline({
return { items, rendered, halted, haltedAt };
}
function dryRunPipeline({
pipeline,
registry,
stderr,
}: {
pipeline: any[];
registry: any;
stderr: any;
}) {
const lines: string[] = [];
lines.push(`[DRY RUN] Pipeline (${pipeline.length} stage${pipeline.length !== 1 ? "s" : ""}):`);
for (let idx = 0; idx < pipeline.length; idx++) {
const stage = pipeline[idx];
const command = registry.get(stage.name);
if (!command) {
throw new Error(`Unknown command: ${stage.name}`);
}
const formattedArgs = stage.args ? formatStageArgs(stage.args) : "";
const argsStr = formattedArgs ? ` args: ${formattedArgs}` : "";
lines.push(` ${idx + 1}. ${stage.name}${argsStr}`);
}
lines.push("");
stderr.write(lines.join("\n"));
// Return rendered:true so the CLI does not print an empty JSON array to stdout.
return { items: [], rendered: true, halted: false, haltedAt: null };
}
function formatStageArgs(args: Record<string, unknown>) {
const parts: string[] = [];
for (const [key, value] of Object.entries(args)) {
if (key === "_") {
const positional = Array.isArray(value) ? value : [value];
for (const v of positional) {
if (v !== undefined && v !== null) parts.push(String(v));
}
} else {
parts.push(`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
}
}
return parts.join(", ");
}
async function* emptyStream() {}

View File

@ -1,32 +1,15 @@
import { runPipelineInternal } from "./runtime.js";
import { encodeToken, decodeToken } from "./token.js";
import { sharedAjv } from "../validation.js";
type SdkResumePayload = {
protocolVersion: 1;
v: 1;
stageIndex?: number;
resumeAtIndex: number;
items?: unknown[];
prompt?: string;
inputSchema?: unknown;
inputSubject?: unknown;
};
import { runPipelineInternal } from './runtime.js';
import { encodeToken, decodeToken } from './token.js';
/**
* @typedef {Object} LobsterResult
* @property {boolean} ok - Whether the workflow completed successfully
* @property {'ok' | 'needs_approval' | 'needs_input' | 'cancelled' | 'error'} status - Workflow status
* @property {'ok' | 'needs_approval' | 'cancelled' | 'error'} status - Workflow status
* @property {any[]} output - Output items from the workflow
* @property {Object|null} requiresApproval - Approval request if halted
* @property {string} [requiresApproval.prompt] - Approval prompt
* @property {any[]} [requiresApproval.items] - Items pending approval
* @property {string} [requiresApproval.resumeToken] - Token to resume workflow
* @property {Object|null} requiresInput - Input request if halted
* @property {string} [requiresInput.prompt] - Input prompt
* @property {Object} [requiresInput.responseSchema] - JSON Schema for response
* @property {any} [requiresInput.subject] - Subject shown to the human
* @property {string} [requiresInput.resumeToken] - Token to resume workflow
* @property {Object} [error] - Error details if failed
*/
@ -36,40 +19,94 @@ type SdkResumePayload = {
* @property {string} [stateDir] - State directory override
*/
/**
* Lobster - Fluent workflow builder for AI agents
*
* @example
* const workflow = new Lobster()
* .pipe(exec('gh pr view 123 --repo owner/repo --json title,url'))
* .pipe(approve({ prompt: 'Continue?' }))
* .run();
*/
export class Lobster {
/** @type {Array<Function|Object>} */
#stages = [];
/** @type {any} */
#options: any = {} as any;
/** @type {Object|null} */
#meta = null;
constructor(options: any = {}) {
/**
* Create a new Lobster workflow builder
* @param {LobsterOptions} [options]
*/
constructor(options: any = {}) {
this.#options = {
env: options.env ?? process.env,
stateDir: options.stateDir,
};
}
/**
* Add a stage to the pipeline
*
* Stages can be:
* - A function: (items: any[]) => any[] | AsyncIterable
* - An async generator function: async function* (input) { ... }
* - A stage object with { run: Function }
* - A primitive from lobster-sdk (approve, exec, etc.)
*
* @param {Function|Object} stage - Stage to add
* @returns {Lobster} - Returns this for chaining
*
* @example
* new Lobster()
* .pipe(exec('gh pr view 123 --repo owner/repo --json title,url'))
* .pipe(items => items)
* .pipe(approve({ prompt: 'Proceed?' }))
*/
pipe(stage) {
if (typeof stage !== "function" && typeof stage?.run !== "function") {
throw new Error("Stage must be a function or have a run() method");
if (typeof stage !== 'function' && typeof stage?.run !== 'function') {
throw new Error('Stage must be a function or have a run() method');
}
this.#stages.push(stage);
return this;
}
/**
* Set metadata for this workflow (for recipe discovery)
* @param {Object} meta
* @param {string} meta.name - Workflow name
* @param {string} meta.description - Description
* @param {string[]} [meta.requires] - Required CLI tools
* @param {Object} [meta.args] - Argument schema
* @returns {Lobster}
*/
meta(meta) {
this.#meta = meta;
return this;
}
/**
* Get workflow metadata
* @returns {Object|null}
*/
getMeta() {
return this.#meta;
}
/**
* Execute the workflow
* @param {any[]} [initialInput] - Optional initial input items
* @returns {Promise<LobsterResult>}
*/
async run(initialInput = []) {
const ctx = {
env: this.#options.env,
stateDir: this.#options.stateDir,
mode: "sdk",
mode: 'sdk',
};
try {
@ -79,11 +116,8 @@ export class Lobster {
input: initialInput,
});
if (
result.halted &&
result.items.length === 1 &&
result.items[0]?.type === "approval_request"
) {
// Check for approval halt
if (result.halted && result.items.length === 1 && result.items[0]?.type === 'approval_request') {
const approval = result.items[0];
const resumeToken = encodeToken({
protocolVersion: 1,
@ -92,152 +126,70 @@ export class Lobster {
resumeAtIndex: (result.haltedAt?.index ?? -1) + 1,
items: approval.items,
prompt: approval.prompt,
// Note: We can't serialize the stages themselves, so resume requires
// the caller to maintain the workflow reference
});
return {
ok: true,
status: "needs_approval",
status: 'needs_approval',
output: [],
requiresApproval: {
prompt: approval.prompt,
items: approval.items,
resumeToken,
},
requiresInput: null,
};
}
if (result.halted && result.items.length === 1 && result.items[0]?.type === "input_request") {
const input = result.items[0];
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
stageIndex: result.haltedAt?.index ?? -1,
resumeAtIndex: (result.haltedAt?.index ?? -1) + 1,
items: [],
inputSchema: input.responseSchema,
inputSubject: input.subject,
});
return {
ok: true,
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: {
prompt: input.prompt,
responseSchema: input.responseSchema,
defaults: input.defaults,
subject: input.subject,
resumeToken,
},
};
}
return {
ok: true,
status: "ok",
status: 'ok',
output: result.items,
requiresApproval: null,
requiresInput: null,
};
} catch (err) {
return {
ok: false,
status: "error",
status: 'error',
output: [],
requiresApproval: null,
requiresInput: null,
error: {
type: "runtime_error",
type: 'runtime_error',
message: err?.message ?? String(err),
},
};
}
}
async resume(
token: string,
options: { approved?: boolean; response?: unknown; cancel?: boolean } = {},
) {
const { approved, response, cancel } = options;
const intentCount =
Number(typeof approved === "boolean") +
Number(response !== undefined) +
Number(cancel === true);
if (intentCount > 1) {
throw new Error("resume accepts only one of approved, response, or cancel");
}
if (intentCount === 0) {
throw new Error("resume requires approved, response, or cancel");
}
const payload = decodeSdkResumePayload(token);
if (cancel === true) {
/**
* Resume a halted workflow after approval
* @param {string} token - Resume token from previous run
* @param {Object} options
* @param {boolean} options.approved - Whether the approval was granted
* @returns {Promise<LobsterResult>}
*/
async resume(token, { approved }) {
if (!approved) {
return {
ok: true,
status: "cancelled",
status: 'cancelled',
output: [],
requiresApproval: null,
requiresInput: null,
};
}
const expectsInput = payload.inputSchema !== undefined;
if (expectsInput) {
if (approved !== undefined) {
throw new Error("resume token expects an input response, not approved");
}
if (response === undefined) {
throw new Error("resume token expects response");
}
} else {
if (response !== undefined) {
throw new Error("resume token expects approved=true|false, not response");
}
if (typeof approved !== "boolean") {
throw new Error("resume token expects approved=true|false");
}
if (approved === false) {
return {
ok: true,
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
};
}
}
const payload = decodeToken(token);
const resumeIndex = payload.resumeAtIndex ?? 0;
let resumeItems = payload.items ?? [];
if (response !== undefined) {
const schema = payload.inputSchema;
if (schema === undefined) {
throw new Error("resume token does not support input responses");
}
let validator;
try {
validator = sharedAjv.compile(schema as any);
} catch {
throw new Error("resume token input schema is invalid");
}
const ok = validator(response);
if (!ok) {
const first = validator.errors?.[0];
throw new Error(
`response does not match schema at ${first?.instancePath || "/"}: ${first?.message || "invalid"}`,
);
}
resumeItems = [response];
}
const resumeItems = payload.items ?? [];
// Get remaining stages
const remainingStages = this.#stages.slice(resumeIndex);
const ctx = {
env: this.#options.env,
stateDir: this.#options.stateDir,
mode: "sdk",
mode: 'sdk',
};
try {
@ -247,11 +199,8 @@ export class Lobster {
input: resumeItems,
});
if (
result.halted &&
result.items.length === 1 &&
result.items[0]?.type === "approval_request"
) {
// Check for another approval halt
if (result.halted && result.items.length === 1 && result.items[0]?.type === 'approval_request') {
const approval = result.items[0];
const resumeToken = encodeToken({
protocolVersion: 1,
@ -264,66 +213,40 @@ export class Lobster {
return {
ok: true,
status: "needs_approval",
status: 'needs_approval',
output: [],
requiresApproval: {
prompt: approval.prompt,
items: approval.items,
resumeToken,
},
requiresInput: null,
};
}
if (result.halted && result.items.length === 1 && result.items[0]?.type === "input_request") {
const input = result.items[0];
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
stageIndex: resumeIndex + (result.haltedAt?.index ?? 0),
resumeAtIndex: resumeIndex + (result.haltedAt?.index ?? 0) + 1,
items: [],
inputSchema: input.responseSchema,
inputSubject: input.subject,
});
return {
ok: true,
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: {
prompt: input.prompt,
responseSchema: input.responseSchema,
defaults: input.defaults,
subject: input.subject,
resumeToken,
},
};
}
return {
ok: true,
status: "ok",
status: 'ok',
output: result.items,
requiresApproval: null,
requiresInput: null,
};
} catch (err) {
return {
ok: false,
status: "error",
status: 'error',
output: [],
requiresApproval: null,
requiresInput: null,
error: {
type: "runtime_error",
type: 'runtime_error',
message: err?.message ?? String(err),
},
};
}
}
/**
* Clone this workflow (for creating variants)
* @returns {Lobster}
*/
clone() {
const cloned = new Lobster(this.#options);
cloned.#stages = [...this.#stages];
@ -331,25 +254,3 @@ export class Lobster {
return cloned;
}
}
function decodeSdkResumePayload(token: string): SdkResumePayload {
const payload = decodeToken(token);
if (!payload || typeof payload !== "object") {
throw new Error("Invalid token");
}
const data = payload as Record<string, unknown>;
if (data.protocolVersion !== 1 || data.v !== 1) {
throw new Error("Invalid token");
}
if (
typeof data.resumeAtIndex !== "number" ||
!Number.isInteger(data.resumeAtIndex) ||
data.resumeAtIndex < 0
) {
throw new Error("Invalid token");
}
if (data.items !== undefined && !Array.isArray(data.items)) {
throw new Error("Invalid token");
}
return data as unknown as SdkResumePayload;
}

View File

@ -17,9 +17,9 @@
* const result = await workflow.run();
*/
export { Lobster } from "./Lobster.js";
export { approve } from "./primitives/approve.js";
export { exec } from "./primitives/exec.js";
export { stateGet, stateSet, state } from "./primitives/state.js";
export { diffLast } from "./primitives/diff.js";
export { runPipeline } from "./runtime.js";
export { Lobster } from './Lobster.js';
export { approve } from './primitives/approve.js';
export { exec } from './primitives/exec.js';
export { stateGet, stateSet, state } from './primitives/state.js';
export { diffLast } from './primitives/diff.js';
export { runPipeline } from './runtime.js';

View File

@ -19,11 +19,11 @@
* @returns {Object} Stage object with run method
*/
export function approve(options: any = {}) {
const prompt = options.prompt ?? "Approve?";
const prompt = options.prompt ?? 'Approve?';
const preview = options.preview !== false;
return {
type: "approve",
type: 'approve',
prompt,
async run({ input, ctx: _ctx }) {
@ -38,7 +38,7 @@ export function approve(options: any = {}) {
halt: true,
output: (async function* () {
yield {
type: "approval_request",
type: 'approval_request',
prompt,
items: preview ? items : [],
itemCount: items.length,

View File

@ -14,9 +14,9 @@
* });
*/
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* Get the state directory
@ -27,7 +27,7 @@ function getStateDir(ctx) {
return (
ctx?.stateDir ||
(ctx?.env?.LOBSTER_STATE_DIR && String(ctx.env.LOBSTER_STATE_DIR).trim()) ||
path.join(os.homedir(), ".lobster", "state")
path.join(os.homedir(), '.lobster', 'state')
);
}
@ -40,10 +40,10 @@ function getStateDir(ctx) {
function keyToPath(stateDir, key) {
const safe = String(key)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!safe) throw new Error("state key is empty/invalid");
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
if (!safe) throw new Error('state key is empty/invalid');
return path.join(stateDir, `${safe}.json`);
}
@ -54,12 +54,8 @@ function keyToPath(stateDir, key) {
*/
function stableStringify(value) {
return JSON.stringify(value, (_k, v) => {
if (v && typeof v === "object" && !Array.isArray(v)) {
return Object.fromEntries(
Object.keys(v)
.sort()
.map((k) => [k, v[k]]),
);
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]));
}
return v;
});
@ -77,12 +73,12 @@ function stableStringify(value) {
* @returns {Object} Stage object with run method
*/
export function diffLast(key, options: any = {}) {
if (!key) throw new Error("diffLast requires a key");
if (!key) throw new Error('diffLast requires a key');
const changesOnly = options.changesOnly === true;
return {
type: "diff.last",
type: 'diff.last',
key,
async run({ input, ctx }) {
@ -100,10 +96,10 @@ export function diffLast(key, options: any = {}) {
// Read previous value
let before = null;
try {
const text = await fsp.readFile(filePath, "utf8");
const text = await fsp.readFile(filePath, 'utf8');
before = JSON.parse(text);
} catch (err) {
if (err?.code !== "ENOENT") {
if (err?.code !== 'ENOENT') {
throw err;
}
}
@ -113,11 +109,11 @@ export function diffLast(key, options: any = {}) {
// Store new value
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
// Build result
const result = {
kind: "diff.last",
kind: 'diff.last',
key,
changed,
before,
@ -128,7 +124,7 @@ export function diffLast(key, options: any = {}) {
if (changesOnly && !changed) {
return {
output: (async function* () {
yield { kind: "diff.last", key, changed: false, suppressed: true };
yield { kind: 'diff.last', key, changed: false, suppressed: true };
})(),
};
}
@ -156,10 +152,10 @@ export async function diffAndStoreValue(key, value, ctx = {}) {
// Read previous value
let before = null;
try {
const text = await fsp.readFile(filePath, "utf8");
const text = await fsp.readFile(filePath, 'utf8');
before = JSON.parse(text);
} catch (err) {
if (err?.code !== "ENOENT") {
if (err?.code !== 'ENOENT') {
throw err;
}
}
@ -169,7 +165,7 @@ export async function diffAndStoreValue(key, value, ctx = {}) {
// Store new value
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
return { before, after: value, changed };
}

View File

@ -9,8 +9,7 @@
* .pipe(items => items.filter(e => e.unread))
*/
import { spawn } from "node:child_process";
import { resolveInlineShellCommand } from "../../shell.js";
import { spawn } from 'node:child_process';
/**
* Run a process and capture output
@ -24,28 +23,24 @@ function runProcess(command, argv, { env, cwd }) {
const child = spawn(command, argv, {
env,
cwd,
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
});
let stdout = "";
let stderr = "";
let stdout = '';
let stderr = '';
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on("data", (d) => {
stdout += d;
});
child.stderr.on("data", (d) => {
stderr += d;
});
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on("error", (err) => {
child.on('error', (err) => {
reject(new Error(`Failed to execute ${command}: ${err.message}`));
});
child.on("close", (code) => {
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
@ -63,14 +58,14 @@ function runProcess(command, argv, { env, cwd }) {
*/
function parseCommand(cmdString) {
const tokens = [];
let current = "";
let current = '';
let quote = null;
for (let i = 0; i < cmdString.length; i++) {
const ch = cmdString[i];
if (quote) {
if (ch === "\\" && cmdString[i + 1]) {
if (ch === '\\' && cmdString[i + 1]) {
current += cmdString[i + 1];
i++;
continue;
@ -88,10 +83,10 @@ function parseCommand(cmdString) {
continue;
}
if (ch === " " || ch === "\t") {
if (ch === ' ' || ch === '\t') {
if (current.length > 0) {
tokens.push(current);
current = "";
current = '';
}
continue;
}
@ -123,7 +118,7 @@ export function exec(cmdString, options: any = {}) {
const cwd = options.cwd ?? process.cwd();
return {
type: "exec",
type: 'exec',
command: cmdString,
async run({ input, ctx }) {
@ -138,8 +133,7 @@ export function exec(cmdString, options: any = {}) {
if (useShell) {
// Shell execution
const shell = resolveInlineShellCommand({ command: cmdString, env });
const result = await runProcess(shell.command, shell.argv, { env, cwd });
const result = await runProcess('/bin/sh', ['-c', cmdString], { env, cwd });
stdout = result.stdout;
} else {
// Direct execution
@ -152,7 +146,7 @@ export function exec(cmdString, options: any = {}) {
let output;
if (parseJson) {
try {
output = JSON.parse(stdout.trim() || "[]");
output = JSON.parse(stdout.trim() || '[]');
} catch {
throw new Error(`exec output is not valid JSON: ${stdout.slice(0, 100)}`);
}

View File

@ -15,9 +15,9 @@
* .pipe(stateSet('my-key'));
*/
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* Get the state directory
@ -28,7 +28,7 @@ function getStateDir(ctx) {
return (
ctx?.stateDir ||
(ctx?.env?.LOBSTER_STATE_DIR && String(ctx.env.LOBSTER_STATE_DIR).trim()) ||
path.join(os.homedir(), ".lobster", "state")
path.join(os.homedir(), '.lobster', 'state')
);
}
@ -41,10 +41,10 @@ function getStateDir(ctx) {
function keyToPath(stateDir, key) {
const safe = String(key)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!safe) throw new Error("state key is empty/invalid");
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
if (!safe) throw new Error('state key is empty/invalid');
return path.join(stateDir, `${safe}.json`);
}
@ -55,10 +55,10 @@ function keyToPath(stateDir, key) {
* @returns {Object} Stage object with run method
*/
export function stateGet(key) {
if (!key) throw new Error("stateGet requires a key");
if (!key) throw new Error('stateGet requires a key');
return {
type: "state.get",
type: 'state.get',
key,
async run({ input, ctx }) {
@ -72,10 +72,10 @@ export function stateGet(key) {
let value = null;
try {
const text = await fsp.readFile(filePath, "utf8");
const text = await fsp.readFile(filePath, 'utf8');
value = JSON.parse(text);
} catch (err) {
if (err?.code !== "ENOENT") {
if (err?.code !== 'ENOENT') {
throw err;
}
// File doesn't exist, return null
@ -97,10 +97,10 @@ export function stateGet(key) {
* @returns {Object} Stage object with run method
*/
export function stateSet(key) {
if (!key) throw new Error("stateSet requires a key");
if (!key) throw new Error('stateSet requires a key');
return {
type: "state.set",
type: 'state.set',
key,
async run({ input, ctx }) {
@ -116,7 +116,7 @@ export function stateSet(key) {
const filePath = keyToPath(stateDir, key);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
// Pass through the value
return {
@ -154,10 +154,10 @@ export async function readState(key, ctx = {}) {
const filePath = keyToPath(stateDir, key);
try {
const text = await fsp.readFile(filePath, "utf8");
const text = await fsp.readFile(filePath, 'utf8');
return JSON.parse(text);
} catch (err) {
if (err?.code === "ENOENT") return null;
if (err?.code === 'ENOENT') return null;
throw err;
}
}
@ -174,5 +174,5 @@ export async function writeState(key, value, ctx = {}) {
const filePath = keyToPath(stateDir, key);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
}

View File

@ -35,12 +35,12 @@ async function* toAsyncIterable(input) {
return;
}
if (typeof input[Symbol.asyncIterator] === "function") {
if (typeof input[Symbol.asyncIterator] === 'function') {
yield* input;
return;
}
if (typeof input[Symbol.iterator] === "function") {
if (typeof input[Symbol.iterator] === 'function') {
for (const item of input) {
yield item;
}
@ -83,11 +83,10 @@ export async function runPipelineInternal({ stages, ctx, input = [] }) {
let result;
if (typeof stage === "function") {
if (typeof stage === 'function') {
// Check if it's a generator function
const isGenerator =
stage.constructor?.name === "AsyncGeneratorFunction" ||
stage.constructor?.name === "GeneratorFunction";
const isGenerator = stage.constructor?.name === 'AsyncGeneratorFunction' ||
stage.constructor?.name === 'GeneratorFunction';
if (isGenerator) {
// Generator function - pass the stream directly
@ -98,7 +97,7 @@ export async function runPipelineInternal({ stages, ctx, input = [] }) {
const output = await stage(items, ctx);
result = { output: toAsyncIterable(output) };
}
} else if (typeof stage?.run === "function") {
} else if (typeof stage?.run === 'function') {
// Stage object with run method (primitives)
result = await stage.run({ input: stream, ctx });
} else {
@ -125,16 +124,7 @@ export async function runPipelineInternal({ stages, ctx, input = [] }) {
/**
* Re-export for compatibility with CLI runtime
*/
export async function runPipeline({
pipeline,
registry,
stdin,
stdout,
stderr,
env,
mode = "human",
input,
}) {
export async function runPipeline({ pipeline, registry, stdin, stdout, stderr, env, mode = 'human', input }) {
// This wraps the CLI-style pipeline execution
// Convert pipeline stages to functions using registry

View File

@ -4,7 +4,7 @@
* Re-exports from the main token module for consistency
*/
import { encodeToken as mainEncode, decodeToken as mainDecode } from "../token.js";
import { encodeToken as mainEncode, decodeToken as mainDecode } from '../token.js';
export const encodeToken = mainEncode;
export const decodeToken = mainDecode;

View File

@ -1,60 +0,0 @@
export function resolveInlineShellCommand({
command,
env,
platform = process.platform,
}: {
command: string;
env: Record<string, string | undefined>;
platform?: string;
}) {
const shellOverride = String(env?.LOBSTER_SHELL ?? "").trim();
const isWindows = platform === "win32";
if (shellOverride) {
return {
command: shellOverride,
argv: buildShellArgs({ shellCommand: shellOverride, command, isWindows }),
};
}
if (isWindows) {
const comspec = String(env?.ComSpec ?? env?.COMSPEC ?? "cmd.exe").trim() || "cmd.exe";
return {
command: comspec,
argv: ["/d", "/s", "/c", command],
};
}
// Keep default behavior deterministic and POSIX-compatible across environments.
const shell = "/bin/sh";
return {
command: shell,
argv: ["-lc", command],
};
}
function buildShellArgs({
shellCommand,
command,
isWindows,
}: {
shellCommand: string;
command: string;
isWindows: boolean;
}) {
const lowered = shellCommand.toLowerCase();
const looksLikeCmd = lowered.endsWith("cmd") || lowered.endsWith("cmd.exe");
const looksLikePowerShell =
lowered.endsWith("powershell") ||
lowered.endsWith("powershell.exe") ||
lowered.endsWith("pwsh") ||
lowered.endsWith("pwsh.exe");
if (looksLikePowerShell) {
return ["-NoProfile", "-Command", command];
}
if (looksLikeCmd || isWindows) {
return ["/d", "/s", "/c", command];
}
return ["-lc", command];
}

View File

@ -1,33 +1,28 @@
import os from "node:os";
import path from "node:path";
import { promises as fsp } from "node:fs";
import { randomBytes } from "node:crypto";
import os from 'node:os';
import path from 'node:path';
import { promises as fsp } from 'node:fs';
export function defaultStateDir(env) {
return (
(env?.LOBSTER_STATE_DIR && String(env.LOBSTER_STATE_DIR).trim()) ||
path.join(os.homedir(), ".lobster", "state")
path.join(os.homedir(), '.lobster', 'state')
);
}
export function keyToPath(stateDir, key) {
const safe = String(key)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!safe) throw new Error("state key is empty/invalid");
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
if (!safe) throw new Error('state key is empty/invalid');
return path.join(stateDir, `${safe}.json`);
}
export function stableStringify(value) {
return JSON.stringify(value, (_k, v) => {
if (v && typeof v === "object" && !Array.isArray(v)) {
return Object.fromEntries(
Object.keys(v)
.sort()
.map((k) => [k, v[k]]),
);
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]));
}
return v;
});
@ -38,10 +33,10 @@ export async function readStateJson({ env, key }) {
const filePath = keyToPath(stateDir, key);
try {
const text = await fsp.readFile(filePath, "utf8");
const text = await fsp.readFile(filePath, 'utf8');
return JSON.parse(text);
} catch (err) {
if (err?.code === "ENOENT") return null;
if (err?.code === 'ENOENT') return null;
throw err;
}
}
@ -51,161 +46,7 @@ export async function writeStateJson({ env, key, value }) {
const filePath = keyToPath(stateDir, key);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
}
export async function deleteStateJson({ env, key }) {
const stateDir = defaultStateDir(env);
const filePath = keyToPath(stateDir, key);
try {
await fsp.unlink(filePath);
} catch (err) {
if (err?.code === "ENOENT") return;
throw err;
}
}
function sanitizeApprovalId(approvalId: string): string {
return approvalId.replace(/[^a-f0-9]/g, "");
}
/**
* Generate a short, human-friendly approval ID (8 hex chars).
* These are easy to copy/paste in chat interfaces where full
* base64url resume tokens are unwieldy.
*/
export function generateApprovalId(): string {
return randomBytes(4).toString("hex");
}
/**
* Write a reverse-index file that maps approvalId stateKey.
* Call this after writeStateJson to enable short-ID resume.
*/
export async function writeApprovalIndex({
env,
stateKey,
approvalId,
}: {
env: Record<string, string | undefined>;
stateKey: string;
approvalId: string;
}) {
const stateDir = defaultStateDir(env);
const safe = sanitizeApprovalId(approvalId);
if (!safe) return;
await fsp.mkdir(stateDir, { recursive: true });
const indexPath = path.join(stateDir, `approval_${safe}.json`);
await fsp.writeFile(
indexPath,
JSON.stringify({ stateKey, createdAt: new Date().toISOString() }) + "\n",
{ encoding: "utf8", flag: "wx", mode: 0o600 },
);
}
/**
* Create a unique approval ID index without ever overwriting an existing mapping.
*/
export async function createApprovalIndex({
env,
stateKey,
}: {
env: Record<string, string | undefined>;
stateKey: string;
}) {
for (let attempt = 0; attempt < 16; attempt++) {
const approvalId = generateApprovalId();
try {
await writeApprovalIndex({ env, stateKey, approvalId });
return approvalId;
} catch (err: any) {
if (err?.code === "EEXIST") continue;
throw err;
}
}
throw new Error("Could not allocate a unique approval ID");
}
/**
* Look up a state key by short approval ID.
* Returns the stateKey string or null if not found.
*/
export async function findStateKeyByApprovalId({
env,
approvalId,
}: {
env: Record<string, string | undefined>;
approvalId: string;
}): Promise<string | null> {
const stateDir = defaultStateDir(env);
const safe = sanitizeApprovalId(approvalId);
if (!safe) return null;
const indexPath = path.join(stateDir, `approval_${safe}.json`);
try {
const text = await fsp.readFile(indexPath, "utf8");
const data = JSON.parse(text);
return typeof data?.stateKey === "string" ? data.stateKey : null;
} catch (err: any) {
if (err?.code === "ENOENT") return null;
throw err;
}
}
/**
* Delete the approval ID index file (cleanup after resume or cancel).
*/
export async function deleteApprovalId({
env,
approvalId,
}: {
env: Record<string, string | undefined>;
approvalId: string;
}) {
const stateDir = defaultStateDir(env);
const safe = sanitizeApprovalId(approvalId);
if (!safe) return;
const indexPath = path.join(stateDir, `approval_${safe}.json`);
try {
await fsp.unlink(indexPath);
} catch (err: any) {
if (err?.code === "ENOENT") return;
throw err;
}
}
/**
* Clean up any approval index file that points to the given stateKey.
* Used when resuming via --token (where we don't know the approvalId).
* Scans index files in the state dir O(n) but n is tiny in practice.
*/
export async function cleanupApprovalIndexByStateKey({
env,
stateKey,
}: {
env: Record<string, string | undefined>;
stateKey: string;
}) {
const stateDir = defaultStateDir(env);
let files: string[];
try {
files = await fsp.readdir(stateDir);
} catch (err: any) {
if (err?.code === "ENOENT") return;
throw err;
}
for (const file of files) {
if (!file.startsWith("approval_") || !file.endsWith(".json")) continue;
try {
const text = await fsp.readFile(path.join(stateDir, file), "utf8");
const data = JSON.parse(text);
if (data?.stateKey === stateKey) {
await fsp.unlink(path.join(stateDir, file)).catch(() => {});
return; // one index per stateKey
}
} catch {
/* skip corrupt files */
}
}
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
}
export async function diffAndStore({ env, key, value }) {

View File

@ -1,15 +1,15 @@
import { Buffer } from "node:buffer";
import { Buffer } from 'node:buffer';
export function encodeToken(obj) {
const json = JSON.stringify(obj);
return Buffer.from(json, "utf8").toString("base64url");
return Buffer.from(json, 'utf8').toString('base64url');
}
export function decodeToken(token) {
try {
const json = Buffer.from(String(token), "base64url").toString("utf8");
const json = Buffer.from(String(token), 'base64url').toString('utf8');
return JSON.parse(json);
} catch (_err) {
throw new Error("Invalid token");
throw new Error('Invalid token');
}
}

View File

@ -1,8 +0,0 @@
import { Ajv } from "ajv";
export const sharedAjv = new Ajv({
allErrors: false,
strict: false,
// User-provided schemas may repeat `$id` across runs/resumes.
addUsedSchema: false,
});

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,37 @@
import { spawn } from "node:child_process";
import { spawn } from 'node:child_process';
function runProcess(command, argv, { env, cwd }) {
return new Promise((resolve, reject) => {
const child = spawn(command, argv, { env, cwd, stdio: ["ignore", "pipe", "pipe"] });
const child = spawn(command, argv, { env, cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = "";
let stderr = "";
let stdout = '';
let stderr = '';
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on("data", (d) => {
stdout += d;
});
child.stderr.on("data", (d) => {
stderr += d;
});
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on("error", (err: any) => {
if (err?.code === "ENOENT") {
reject(new Error("gh not found on PATH (install GitHub CLI)"));
child.on('error', (err: any) => {
if (err?.code === 'ENOENT') {
reject(new Error('gh not found on PATH (install GitHub CLI)'));
return;
}
reject(err);
});
child.on("close", (code) => {
child.on('close', (code) => {
if (code === 0) return resolve({ stdout, stderr });
reject(new Error(`gh failed (${code}): ${stderr.trim() || stdout.trim()}`));
});
});
}
import { diffAndStore } from "../state/store.js";
import { diffAndStore } from '../state/store.js';
function pickSubset(snapshot) {
if (!snapshot || typeof snapshot !== "object") return null;
if (!snapshot || typeof snapshot !== 'object') return null;
return {
number: snapshot.number,
title: snapshot.title,
@ -76,45 +72,45 @@ export function buildPrChangeSummary(before, after) {
}
function formatPrChangeMessage({ repo, pr, changedFields, prInfo }) {
const fields = changedFields.length ? ` (${changedFields.join(", ")})` : "";
const title = prInfo?.title ? `: ${prInfo.title}` : "";
const url = prInfo?.url ? ` ${prInfo.url}` : "";
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, " ").trim();
const fields = changedFields.length ? ` (${changedFields.join(', ')})` : '';
const title = prInfo?.title ? `: ${prInfo.title}` : '';
const url = prInfo?.url ? ` ${prInfo.url}` : '';
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, ' ').trim();
}
export async function runGithubPrMonitorWorkflow({ args, ctx }) {
const repo = args.repo;
const pr = args.pr;
if (!repo || !pr) throw new Error("github.pr.monitor requires args.repo and args.pr");
if (!repo || !pr) throw new Error('github.pr.monitor requires args.repo and args.pr');
const key = args.key ?? `github.pr:${repo}#${pr}`;
const changesOnly = Boolean(args.changesOnly);
const summaryOnly = Boolean(args.summaryOnly);
const argv = [
"pr",
"view",
'pr',
'view',
String(pr),
"--repo",
'--repo',
String(repo),
"--json",
"number,title,url,state,isDraft,mergeable,reviewDecision,author,baseRefName,headRefName,updatedAt",
'--json',
'number,title,url,state,isDraft,mergeable,reviewDecision,author,baseRefName,headRefName,updatedAt',
];
const { stdout } = (await runProcess("gh", argv, { env: ctx.env, cwd: process.cwd() })) as any;
const { stdout } = (await runProcess('gh', argv, { env: ctx.env, cwd: process.cwd() })) as any;
let current;
try {
current = JSON.parse(stdout.trim());
} catch {
throw new Error("gh returned non-JSON output");
throw new Error('gh returned non-JSON output');
}
const { changed, before } = await diffAndStore({ env: ctx.env, key, value: current });
if (changesOnly && !changed) {
return {
kind: "github.pr.monitor",
kind: 'github.pr.monitor',
repo,
prNumber: Number(pr),
key,
@ -127,7 +123,7 @@ export async function runGithubPrMonitorWorkflow({ args, ctx }) {
if (summaryOnly) {
return {
kind: "github.pr.monitor",
kind: 'github.pr.monitor',
repo,
prNumber: Number(pr),
key,
@ -144,7 +140,7 @@ export async function runGithubPrMonitorWorkflow({ args, ctx }) {
}
return {
kind: "github.pr.monitor",
kind: 'github.pr.monitor',
repo,
prNumber: Number(pr),
key,
@ -165,14 +161,14 @@ export async function runGithubPrMonitorNotifyWorkflow({ args, ctx }) {
});
if (base.suppressed) {
return { kind: "github.pr.monitor.notify", suppressed: true };
return { kind: 'github.pr.monitor.notify', suppressed: true };
}
const changedFields = base.summary?.changedFields ?? [];
const prInfo = base.pr ?? {};
return {
kind: "github.pr.monitor.notify",
kind: 'github.pr.monitor.notify',
changed: Boolean(base.changed),
repo: args.repo,
prNumber: Number(args.pr),

View File

@ -1,253 +0,0 @@
import type { WorkflowFile, WorkflowStep } from "./file.js";
export type WorkflowGraphFormat = "mermaid" | "dot" | "ascii";
type GraphNode = {
id: string;
type: string;
label: string;
shape: "box" | "diamond";
};
type GraphEdge = {
from: string;
to: string;
label?: string;
};
type RenderGraphParams = {
workflow: WorkflowFile;
format: WorkflowGraphFormat;
args?: Record<string, unknown>;
};
function resolveArgsTemplate(input: string, args: Record<string, unknown>) {
return input.replace(/\$\{([A-Za-z0-9_-]+)\}/g, (match, key) => {
if (key in args) return String(args[key]);
return match;
});
}
function isApprovalStep(step: WorkflowStep) {
if (step.approval === true) return true;
if (typeof step.approval === "string" && step.approval.trim().length > 0) return true;
if (step.approval && typeof step.approval === "object" && !Array.isArray(step.approval))
return true;
return false;
}
function isInputStep(step: WorkflowStep) {
return Boolean(step.input && typeof step.input === "object" && !Array.isArray(step.input));
}
function stepType(step: WorkflowStep) {
if (step.parallel) return "parallel";
if (typeof step.for_each === "string") return "for_each";
if (typeof step.workflow === "string" && step.workflow.trim()) return "workflow";
if (typeof step.pipeline === "string" && step.pipeline.trim()) return "pipeline";
if (typeof step.run === "string" || typeof step.command === "string") return "run";
if (isApprovalStep(step)) return "approval";
if (isInputStep(step)) return "input";
return "step";
}
function stepDetails(step: WorkflowStep, args: Record<string, unknown>) {
if (step.parallel) {
return `parallel (${step.parallel.wait ?? "all"})`;
}
if (typeof step.for_each === "string") {
return `for_each: ${resolveArgsTemplate(step.for_each, args)}`;
}
if (typeof step.workflow === "string" && step.workflow.trim()) {
return `workflow: ${resolveArgsTemplate(step.workflow, args)}`;
}
if (typeof step.pipeline === "string" && step.pipeline.trim()) {
return `pipeline: ${resolveArgsTemplate(step.pipeline, args)}`;
}
const shell = typeof step.run === "string" ? step.run : step.command;
if (typeof shell === "string" && shell.trim()) {
return `run: ${resolveArgsTemplate(shell, args)}`;
}
if (isApprovalStep(step)) return "approval gate";
if (isInputStep(step)) return "input request";
return "";
}
function extractStepRefsFromString(value: string): string[] {
const refs = new Set<string>();
const rx = /\$([A-Za-z0-9_-]+)\.[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*/g;
for (const m of value.matchAll(rx)) {
if (m[1]) refs.add(m[1]);
}
return [...refs];
}
function extractStepRefs(value: unknown): string[] {
if (typeof value === "string") return extractStepRefsFromString(value);
if (Array.isArray(value)) {
const refs = new Set<string>();
for (const item of value) {
for (const ref of extractStepRefs(item)) refs.add(ref);
}
return [...refs];
}
if (value && typeof value === "object") {
const refs = new Set<string>();
for (const v of Object.values(value as Record<string, unknown>)) {
for (const ref of extractStepRefs(v)) refs.add(ref);
}
return [...refs];
}
return [];
}
function truncate(value: string, max = 80) {
if (value.length <= max) return value;
return `${value.slice(0, max - 1)}`;
}
function collectGraph(workflow: WorkflowFile, args: Record<string, unknown>) {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const knownStepIds = new Set(workflow.steps.map((s) => s.id));
let prevStepId: string | null = null;
const seenEdgeKeys = new Set<string>();
const addEdge = (edge: GraphEdge) => {
const key = `${edge.from}|${edge.to}|${edge.label ?? ""}`;
if (seenEdgeKeys.has(key)) return;
seenEdgeKeys.add(key);
edges.push(edge);
};
for (const step of workflow.steps) {
const type = stepType(step);
const details = stepDetails(step, args);
const label = details ? `${step.id}\\n${truncate(details)}` : step.id;
nodes.push({
id: step.id,
type,
label,
shape: isApprovalStep(step) ? "diamond" : "box",
});
if (prevStepId) {
addEdge({ from: prevStepId, to: step.id, label: "next" });
}
prevStepId = step.id;
for (const ref of extractStepRefs(step.stdin)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: "stdin" });
}
if (typeof step.for_each === "string") {
for (const ref of extractStepRefs(step.for_each)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: "for_each" });
}
}
const condition = step.when ?? step.condition;
if (typeof condition === "string" && condition.trim()) {
const labelValue = truncate(`when: ${condition.trim()}`, 70);
for (const ref of extractStepRefs(condition)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: labelValue });
}
}
}
return { nodes, edges };
}
function sanitizeMermaidId(id: string) {
return id.replace(/[^A-Za-z0-9_]/g, "_");
}
function escapeMermaidLabel(value: string) {
return value.replace(/"/g, '\\"');
}
function renderMermaid(nodes: GraphNode[], edges: GraphEdge[]) {
const idMap = new Map<string, string>();
const used = new Set<string>();
for (const node of nodes) {
let key = sanitizeMermaidId(node.id) || "step";
if (/^\d/.test(key)) key = `s_${key}`;
let i = 2;
while (used.has(key)) {
key = `${sanitizeMermaidId(node.id)}_${i}`;
i += 1;
}
used.add(key);
idMap.set(node.id, key);
}
const lines = ["flowchart TD"];
for (const node of nodes) {
const key = idMap.get(node.id)!;
const label = escapeMermaidLabel(node.label);
if (node.shape === "diamond") {
lines.push(` ${key}{"${label}"}`);
} else {
lines.push(` ${key}["${label}"]`);
}
}
if (nodes.length) lines.push("");
for (const edge of edges) {
const from = idMap.get(edge.from);
const to = idMap.get(edge.to);
if (!from || !to) continue;
if (edge.label) {
lines.push(` ${from} -->|${escapeMermaidLabel(edge.label)}| ${to}`);
} else {
lines.push(` ${from} --> ${to}`);
}
}
return lines.join("\n");
}
function escapeDot(value: string) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function renderDot(nodes: GraphNode[], edges: GraphEdge[]) {
const lines = ["digraph workflow {", " rankdir=TB;"];
for (const node of nodes) {
const shape = node.shape === "diamond" ? "diamond" : "box";
lines.push(` "${escapeDot(node.id)}" [shape=${shape},label="${escapeDot(node.label)}"];`);
}
if (nodes.length) lines.push("");
for (const edge of edges) {
if (edge.label) {
lines.push(
` "${escapeDot(edge.from)}" -> "${escapeDot(edge.to)}" [label="${escapeDot(edge.label)}"];`,
);
} else {
lines.push(` "${escapeDot(edge.from)}" -> "${escapeDot(edge.to)}";`);
}
}
lines.push("}");
return lines.join("\n");
}
function renderAscii(nodes: GraphNode[], edges: GraphEdge[]) {
const lines = ["Workflow Graph", "", "Nodes:"];
for (const node of nodes) {
lines.push(
`- ${node.id} [${node.type}] ${node.label.includes("\\n") ? `(${node.label.split("\\n")[1]})` : ""}`.trim(),
);
}
lines.push("", "Edges:");
for (const edge of edges) {
lines.push(`- ${edge.from} -> ${edge.to}${edge.label ? ` (${edge.label})` : ""}`);
}
if (edges.length === 0) lines.push("- (none)");
return lines.join("\n");
}
export function renderWorkflowGraph({ workflow, format, args = {} }: RenderGraphParams) {
const { nodes, edges } = collectGraph(workflow, args);
if (format === "dot") return renderDot(nodes, edges);
if (format === "ascii") return renderAscii(nodes, edges);
return renderMermaid(nodes, edges);
}

View File

@ -0,0 +1,118 @@
import { parsePipeline } from '../parser.js';
import { runPipeline } from '../runtime.js';
function assertStyle(value: unknown): 'sassy' | 'professional' | 'drybread' {
const v = String(value ?? 'professional').trim().toLowerCase();
if (v === 'sassy' || v === 'professional' || v === 'drybread') return v;
return 'professional';
}
function jsonEscape(s: string) {
return s.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
}
function buildContextShell({ repoDir, sinceRef, maxCommits, link, style }: any) {
// Produce a single JSON object on stdout.
// Uses jq for safe JSON construction.
return [
`cd '${jsonEscape(repoDir)}'`,
`SINCE='${jsonEscape(sinceRef || `HEAD~${Number(maxCommits) || 30}`)}'`,
`COMMITS=$(git log --no-merges --pretty=format:'%h %s (%an)' "$SINCE..HEAD" | head -n 80 | jq -R -s -c 'split("\\n")|map(select(length>0))')`,
// Grab the topmost changelog section (first "## " header block)
`CHANGELOG=$(node -e "const fs=require('fs'); const t=fs.readFileSync('CHANGELOG.md','utf8'); const parts=t.split(/\\n## /); const top=(parts.length>1?('## '+parts[1]).split(/\\n## /)[0]:t); process.stdout.write(top.slice(0,6000));")`,
`jq -n --arg repo 'openclaw' --arg style '${jsonEscape(style)}' --arg since "$SINCE" --arg link '${jsonEscape(link)}' --argjson commits "$COMMITS" --arg changelog "$CHANGELOG" '{repo:$repo,style:$style,since:$since,link:$link,commits:$commits,changelog:$changelog}'`,
].join(' && ');
}
function buildTweetPrompt() {
return (
`You are writing a release tweet for OpenClaw.\n\n` +
`Input JSON has: style (sassy|professional|drybread), since, link, commits[], changelog.\n\n` +
`Write ONE tweet in the requested style.\n` +
`Constraints:\n` +
`- <= 260 characters\n` +
`- Include the link exactly once (use the provided link field)\n` +
`- Don\'t hallucinate features\n` +
`- No hashtags unless truly helpful (max 1)\n\n` +
`Return JSON: {"tweet":"...","style":"..."}.\n\n` +
`INPUT:\n{{.}}`
);
}
export async function runOpenclawReleaseTweetWorkflow({ args, ctx }: any) {
const repoDir = String(args.repo_dir ?? args.repoDir ?? '../openclaw');
const style = assertStyle(args.style);
const sinceRef = String(args.since_ref ?? args.sinceRef ?? '').trim();
const maxCommits = Number(args.max_commits ?? args.maxCommits ?? 30);
const link = String(args.link ?? 'https://openclaw.dev');
const contextShell = buildContextShell({ repoDir, sinceRef, maxCommits, link, style });
const pipelineString = [
`exec --json --shell '${jsonEscape(contextShell)}'`,
`llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"},"style":{"type":"string"}},"required":["tweet","style"],"additionalProperties":false}' --prompt '${jsonEscape(buildTweetPrompt())}'`,
// Human approval (interactive by default; emits approval_request in tool/non-tty)
`approve --preview-from-stdin --limit 1 --prompt 'Post this release tweet?'`,
// Output tweet object (for display or piping)
`pick tweet,style`,
].join(' | ');
const pipeline = parsePipeline(pipelineString);
const output = await runPipeline({
pipeline,
registry: ctx.registry,
input: [],
stdin: ctx.stdin,
stdout: ctx.stdout,
stderr: ctx.stderr,
env: ctx.env,
mode: ctx.mode,
});
// In human mode, approve passes items through. In tool mode, approve halts; cli will wrap.
return {
kind: 'openclaw.release.tweet',
style,
since: sinceRef || `HEAD~${maxCommits}`,
items: output.items,
halted: output.halted,
};
}
export async function runOpenclawReleasePostWorkflow({ args, ctx }: any) {
const repoDir = String(args.repo_dir ?? args.repoDir ?? '../openclaw');
const style = assertStyle(args.style);
const sinceRef = String(args.since_ref ?? args.sinceRef ?? '').trim();
const maxCommits = Number(args.max_commits ?? args.maxCommits ?? 30);
const link = String(args.link ?? 'https://openclaw.dev');
const contextShell = buildContextShell({ repoDir, sinceRef, maxCommits, link, style });
const pipelineString = [
`exec --json --shell '${jsonEscape(contextShell)}'`,
`llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"},"style":{"type":"string"}},"required":["tweet","style"],"additionalProperties":false}' --prompt '${jsonEscape(buildTweetPrompt())}'`,
`approve --preview-from-stdin --limit 1 --prompt 'Post this release tweet to X?'`,
// Post to X via bird; uses stdin json array from previous stage.
`exec --stdin json --shell 'T=$(cat | jq -r ".[0].tweet"); bird post --text "$T"; echo "{\\"posted\\":true}"' --json`,
].join(' | ');
const pipeline = parsePipeline(pipelineString);
const output = await runPipeline({
pipeline,
registry: ctx.registry,
input: [],
stdin: ctx.stdin,
stdout: ctx.stdout,
stderr: ctx.stderr,
env: ctx.env,
mode: ctx.mode,
});
return {
kind: 'openclaw.release.post',
style,
since: sinceRef || `HEAD~${maxCommits}`,
items: output.items,
halted: output.halted,
};
}

View File

@ -1,44 +1,87 @@
export const workflowRegistry = {
"github.pr.monitor": {
name: "github.pr.monitor",
description: "Fetch PR state via gh, diff against last run, emit only on change.",
'openclaw.release.tweet': {
name: 'openclaw.release.tweet',
description:
'Generate an OpenClaw release tweet (sassy/professional/drybread) from commits + changelog, with approval.',
argsSchema: {
type: "object",
type: 'object',
properties: {
repo: { type: "string", description: "owner/repo (e.g. openclaw/openclaw)" },
pr: { type: "number", description: "Pull request number" },
key: { type: "string", description: "Optional state key override." },
changesOnly: { type: "boolean", description: "If true, suppress output when unchanged." },
summaryOnly: {
type: "boolean",
description: "If true, return only a compact change summary (smaller output).",
},
repo_dir: { type: 'string', description: 'Path to OpenClaw repo (default: ../openclaw)' },
since_ref: { type: 'string', description: 'Start ref (tag/sha). Default: HEAD~max_commits' },
max_commits: { type: 'number', description: 'Commit window when since_ref is empty (default: 30)' },
link: { type: 'string', description: 'Release notes link to include (default: https://openclaw.dev)' },
style: { type: 'string', description: 'sassy|professional|drybread (default: professional)' },
},
required: ["repo", "pr"],
required: [],
},
examples: [
{
args: { repo: "openclaw/openclaw", pr: 1152 },
description: "Monitor a PR and report when it changes.",
args: { style: 'sassy', since_ref: 'HEAD~30', link: 'https://openclaw.dev' },
description: 'Generate a sassy tweet from last 30 commits.',
},
],
sideEffects: [],
},
"github.pr.monitor.notify": {
name: "github.pr.monitor.notify",
description: "Monitor a PR and emit a single human-friendly message when it changes.",
'openclaw.release.post': {
name: 'openclaw.release.post',
description:
'Generate an OpenClaw release tweet and (after approval) post to X via bird CLI.',
argsSchema: {
type: "object",
type: 'object',
properties: {
repo: { type: "string", description: "owner/repo (e.g. openclaw/openclaw)" },
pr: { type: "number", description: "Pull request number" },
key: { type: "string", description: "Optional state key override." },
repo_dir: { type: 'string', description: 'Path to OpenClaw repo (default: ../openclaw)' },
since_ref: { type: 'string', description: 'Start ref (tag/sha). Default: HEAD~max_commits' },
max_commits: { type: 'number', description: 'Commit window when since_ref is empty (default: 30)' },
link: { type: 'string', description: 'Release notes link to include (default: https://openclaw.dev)' },
style: { type: 'string', description: 'sassy|professional|drybread (default: professional)' },
},
required: ["repo", "pr"],
required: [],
},
examples: [
{
args: { repo: "openclaw/openclaw", pr: 1152 },
args: { style: 'professional', since_ref: 'HEAD~30', link: 'https://openclaw.dev' },
description: 'Approve and post a professional tweet.',
},
],
sideEffects: ['local_exec'],
},
'github.pr.monitor': {
name: 'github.pr.monitor',
description: 'Fetch PR state via gh, diff against last run, emit only on change.',
argsSchema: {
type: 'object',
properties: {
repo: { type: 'string', description: 'owner/repo (e.g. clawdbot/clawdbot)' },
pr: { type: 'number', description: 'Pull request number' },
key: { type: 'string', description: 'Optional state key override.' },
changesOnly: { type: 'boolean', description: 'If true, suppress output when unchanged.' },
summaryOnly: { type: 'boolean', description: 'If true, return only a compact change summary (smaller output).' },
},
required: ['repo', 'pr'],
},
examples: [
{
args: { repo: 'clawdbot/clawdbot', pr: 1152 },
description: 'Monitor a PR and report when it changes.',
},
],
sideEffects: [],
},
'github.pr.monitor.notify': {
name: 'github.pr.monitor.notify',
description: 'Monitor a PR and emit a single human-friendly message when it changes.',
argsSchema: {
type: 'object',
properties: {
repo: { type: 'string', description: 'owner/repo (e.g. clawdbot/clawdbot)' },
pr: { type: 'number', description: 'Pull request number' },
key: { type: 'string', description: 'Optional state key override.' },
},
required: ['repo', 'pr'],
},
examples: [
{
args: { repo: 'clawdbot/clawdbot', pr: 1152 },
description: 'Emit "PR updated" message only when changed.',
},
],

View File

@ -1,215 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { findStateKeyByApprovalId, writeApprovalIndex } from "../src/state/store.js";
function runCli(args: string[], env: Record<string, string | undefined>) {
const bin = path.join(process.cwd(), "bin", "lobster.js");
return spawnSync("node", [bin, ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
});
}
test("approval gate returns approvalId alongside resumeToken", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{a:1}]))'\" | approve --prompt 'ok?' | pick a";
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
assert.equal(first.status, 0);
const json = JSON.parse(first.stdout);
assert.equal(json.status, "needs_approval");
assert.ok(json.requiresApproval?.resumeToken, "should have resumeToken");
assert.ok(json.requiresApproval?.approvalId, "should have approvalId");
assert.equal(json.requiresApproval.approvalId.length, 8, "approvalId should be 8 hex chars");
assert.match(json.requiresApproval.approvalId, /^[a-f0-9]{8}$/, "approvalId should be hex");
// Verify index file was written
const files = await fsp.readdir(stateDir);
const indexFiles = files.filter((name) => name.startsWith("approval_"));
assert.equal(indexFiles.length, 1, "should have one approval index file");
});
test("resume with --id works as alternative to --token", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-resume-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{b:2}]))'\" | approve --prompt 'ok?' | pick b";
// Step 1: Run pipeline, get approval ID
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
assert.equal(first.status, 0);
const firstJson = JSON.parse(first.stdout);
assert.equal(firstJson.status, "needs_approval");
const approvalId = firstJson.requiresApproval.approvalId;
assert.ok(approvalId);
// Step 2: Resume using --id instead of --token
const resumed = runCli(["resume", "--id", approvalId, "--approve", "yes"], {
LOBSTER_STATE_DIR: stateDir,
});
assert.equal(resumed.status, 0, `stderr: ${resumed.stderr}`);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, "ok");
assert.deepEqual(resumedJson.output, [{ b: 2 }]);
// Step 3: Verify cleanup — approval index should be deleted
const files = await fsp.readdir(stateDir);
const indexFiles = files.filter((name) => name.startsWith("approval_"));
assert.equal(indexFiles.length, 0, "approval index should be cleaned up after resume");
});
test("resume with --id cancellation works", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-cancel-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{c:3}]))'\" | approve --prompt 'ok?' | pick c";
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
const firstJson = JSON.parse(first.stdout);
const approvalId = firstJson.requiresApproval.approvalId;
const cancelled = runCli(["resume", "--id", approvalId, "--approve", "no"], {
LOBSTER_STATE_DIR: stateDir,
});
assert.equal(cancelled.status, 0);
const cancelledJson = JSON.parse(cancelled.stdout);
assert.equal(cancelledJson.status, "cancelled");
});
test("resume with invalid --id returns clear error", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-invalid-"));
const stateDir = path.join(tmpDir, "state");
const result = runCli(["resume", "--id", "deadbeef", "--approve", "yes"], {
LOBSTER_STATE_DIR: stateDir,
});
// Should fail with a clear error message
const json = JSON.parse(result.stdout);
assert.equal(json.ok, false);
assert.ok(
json.error?.message?.includes("not found"),
`Error should mention not found: ${json.error?.message}`,
);
});
test("--token resume cleans up orphaned approval index", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-orphan-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{e:5}]))'\" | approve --prompt 'ok?' | pick e";
// Step 1: Run pipeline, get both approvalId and resumeToken
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
const firstJson = JSON.parse(first.stdout);
assert.ok(firstJson.requiresApproval?.approvalId);
assert.ok(firstJson.requiresApproval?.resumeToken);
// Verify index file exists
let files = await fsp.readdir(stateDir);
let indexFiles = files.filter((name) => name.startsWith("approval_"));
assert.equal(indexFiles.length, 1, "approval index should exist before resume");
// Step 2: Resume using --token (NOT --id)
const resumed = runCli(
["resume", "--token", firstJson.requiresApproval.resumeToken, "--approve", "yes"],
{ LOBSTER_STATE_DIR: stateDir },
);
assert.equal(resumed.status, 0);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, "ok");
// Step 3: Verify approval index was cleaned up despite using --token
files = await fsp.readdir(stateDir);
indexFiles = files.filter((name) => name.startsWith("approval_"));
assert.equal(indexFiles.length, 0, "approval index should be cleaned up even when using --token");
});
test("double-resume with same --id returns clear error", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-double-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{f:6}]))'\" | approve --prompt 'ok?' | pick f";
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
const firstJson = JSON.parse(first.stdout);
const approvalId = firstJson.requiresApproval.approvalId;
// First resume — should succeed
const resumed = runCli(["resume", "--id", approvalId, "--approve", "yes"], {
LOBSTER_STATE_DIR: stateDir,
});
assert.equal(resumed.status, 0);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, "ok");
// Second resume with same ID — should fail cleanly, not crash
const second = runCli(["resume", "--id", approvalId, "--approve", "yes"], {
LOBSTER_STATE_DIR: stateDir,
});
const secondJson = JSON.parse(second.stdout);
assert.equal(secondJson.ok, false);
assert.ok(
secondJson.error?.message?.includes("not found"),
`Should report not found: ${secondJson.error?.message}`,
);
});
test("backward compat: --token still works when approvalId is present", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-compat-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{d:4}]))'\" | approve --prompt 'ok?' | pick d";
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
const firstJson = JSON.parse(first.stdout);
assert.ok(firstJson.requiresApproval?.approvalId, "approvalId present");
assert.ok(firstJson.requiresApproval?.resumeToken, "resumeToken present");
// Resume using the old --token approach — should still work
const resumed = runCli(
["resume", "--token", firstJson.requiresApproval.resumeToken, "--approve", "yes"],
{ LOBSTER_STATE_DIR: stateDir },
);
assert.equal(resumed.status, 0);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, "ok");
assert.deepEqual(resumedJson.output, [{ d: 4 }]);
});
test("approval index writes never overwrite an existing approval ID mapping", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-collision-"));
const stateDir = path.join(tmpDir, "state");
const env = { LOBSTER_STATE_DIR: stateDir };
await writeApprovalIndex({
env,
stateKey: "workflow_resume_original",
approvalId: "deadbeef",
});
await assert.rejects(
() =>
writeApprovalIndex({
env,
stateKey: "workflow_resume_replacement",
approvalId: "deadbeef",
}),
(err: NodeJS.ErrnoException) => err?.code === "EEXIST",
);
const resolved = await findStateKeyByApprovalId({ env, approvalId: "deadbeef" });
assert.equal(resolved, "workflow_resume_original");
});

View File

@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createDefaultRegistry } from "../src/commands/registry.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDefaultRegistry } from '../src/commands/registry.js';
function streamOf(items) {
return (async function* () {
@ -8,17 +8,17 @@ function streamOf(items) {
})();
}
test("approve preview includes stdin sample when requested", async () => {
test('approve preview includes stdin sample when requested', async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("approve");
const cmd = registry.get('approve');
const result = await cmd.run({
input: streamOf([{ a: 1 }, { a: 2 }]),
args: {
_: [],
emit: true,
prompt: "ok?",
"preview-from-stdin": true,
prompt: 'ok?',
'preview-from-stdin': true,
limit: 1,
},
ctx: {
@ -27,13 +27,13 @@ test("approve preview includes stdin sample when requested", async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: "tool",
mode: 'tool',
render: { json() {}, lines() {} },
},
});
const items = [];
for await (const item of result.output) items.push(item);
assert.equal(items[0].type, "approval_request");
assert.equal(items[0].type, 'approval_request');
assert.ok(String(items[0].preview).includes('"a": 1'));
});

View File

@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { createDefaultRegistry } from "../src/commands/registry.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createDefaultRegistry } from '../src/commands/registry.js';
function streamOf(items) {
return (async function* () {
@ -9,22 +9,22 @@ function streamOf(items) {
})();
}
test("openclaw.invoke posts to /tools/invoke and returns JSON", async () => {
test('clawd.invoke posts to /tools/invoke and returns JSON', async () => {
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
res.writeHead(404);
res.end("not found");
res.end('not found');
return;
}
let body = "";
req.setEncoding("utf8");
req.on("data", (d) => (body += d));
req.on("end", () => {
let body = '';
req.setEncoding('utf8');
req.on('data', (d) => (body += d));
req.on('end', () => {
const parsed = JSON.parse(body);
assert.equal(parsed.tool, "demo");
assert.equal(parsed.action, "ping");
res.writeHead(200, { "content-type": "application/json" });
assert.equal(parsed.tool, 'demo');
assert.equal(parsed.action, 'ping');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, result: [{ ok: true, echo: parsed.args }] }));
});
});
@ -35,16 +35,16 @@ test("openclaw.invoke posts to /tools/invoke and returns JSON", async () => {
try {
const registry = createDefaultRegistry();
const cmd = registry.get("openclaw.invoke");
const cmd = registry.get('clawd.invoke');
const result = await cmd.run({
input: streamOf([]),
args: {
_: [],
url: `http://127.0.0.1:${port}`,
tool: "demo",
action: "ping",
"args-json": '{"hello":"world"}',
tool: 'demo',
action: 'ping',
'args-json': '{"hello":"world"}',
},
ctx: {
stdin: process.stdin,
@ -52,35 +52,35 @@ test("openclaw.invoke posts to /tools/invoke and returns JSON", async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: "tool",
mode: 'tool',
render: { json() {}, lines() {} },
},
});
const items = [];
for await (const it of result.output) items.push(it);
assert.deepEqual(items, [{ ok: true, echo: { hello: "world" } }]);
assert.deepEqual(items, [{ ok: true, echo: { hello: 'world' } }]);
} finally {
server.close();
}
});
test("openclaw.invoke --each maps input items into tool args", async () => {
test('clawd.invoke --each maps input items into tool args', async () => {
const seen: Array<{ call: number; args: unknown }> = [];
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
res.writeHead(404);
res.end("not found");
res.end('not found');
return;
}
let body = "";
req.setEncoding("utf8");
req.on("data", (d) => (body += d));
req.on("end", () => {
let body = '';
req.setEncoding('utf8');
req.on('data', (d) => (body += d));
req.on('end', () => {
const parsed = JSON.parse(body);
seen.push({ call: seen.length + 1, args: parsed.args });
res.writeHead(200, { "content-type": "application/json" });
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, result: [{ ok: true, call: seen.length }] }));
});
});
@ -91,18 +91,18 @@ test("openclaw.invoke --each maps input items into tool args", async () => {
try {
const registry = createDefaultRegistry();
const cmd = registry.get("openclaw.invoke");
const cmd = registry.get('clawd.invoke');
const result = await cmd.run({
input: streamOf(["a", "b"]),
input: streamOf(['a', 'b']),
args: {
_: [],
url: `http://127.0.0.1:${port}`,
tool: "demo",
action: "ping",
tool: 'demo',
action: 'ping',
each: true,
"item-key": "message",
"args-json": '{"channel":"test"}',
'item-key': 'message',
'args-json': '{"channel":"test"}',
},
ctx: {
stdin: process.stdin,
@ -110,20 +110,17 @@ test("openclaw.invoke --each maps input items into tool args", async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: "tool",
mode: 'tool',
render: { json() {}, lines() {} },
},
});
const items = [];
for await (const it of result.output) items.push(it);
assert.deepEqual(items, [
{ ok: true, call: 1 },
{ ok: true, call: 2 },
]);
assert.deepEqual(items, [{ ok: true, call: 1 }, { ok: true, call: 2 }]);
assert.deepEqual(seen, [
{ call: 1, args: { channel: "test", message: "a" } },
{ call: 2, args: { channel: "test", message: "b" } },
{ call: 1, args: { channel: 'test', message: 'a' } },
{ call: 2, args: { channel: 'test', message: 'b' } },
]);
} finally {
server.close();

View File

@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { createDefaultRegistry } from "../src/commands/registry.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createDefaultRegistry } from '../src/commands/registry.js';
function streamOf(items) {
return (async function* () {
@ -9,22 +9,22 @@ function streamOf(items) {
})();
}
test("openclaw.invoke accepts legacy raw JSON response", async () => {
test('clawd.invoke accepts legacy raw JSON response', async () => {
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
res.writeHead(404);
res.end("not found");
res.end('not found');
return;
}
let body = "";
req.setEncoding("utf8");
req.on("data", (d) => (body += d));
req.on("end", () => {
let body = '';
req.setEncoding('utf8');
req.on('data', (d) => (body += d));
req.on('end', () => {
const parsed = JSON.parse(body);
assert.equal(parsed.tool, "demo");
assert.equal(parsed.action, "ping");
res.writeHead(200, { "content-type": "application/json" });
assert.equal(parsed.tool, 'demo');
assert.equal(parsed.action, 'ping');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify([{ ok: true, legacy: true, echo: parsed.args }]));
});
});
@ -35,16 +35,16 @@ test("openclaw.invoke accepts legacy raw JSON response", async () => {
try {
const registry = createDefaultRegistry();
const cmd = registry.get("openclaw.invoke");
const cmd = registry.get('clawd.invoke');
const result = await cmd.run({
input: streamOf([]),
args: {
_: [],
url: `http://127.0.0.1:${port}`,
tool: "demo",
action: "ping",
"args-json": '{"hello":"world"}',
tool: 'demo',
action: 'ping',
'args-json': '{"hello":"world"}',
},
ctx: {
stdin: process.stdin,
@ -52,14 +52,14 @@ test("openclaw.invoke accepts legacy raw JSON response", async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: "tool",
mode: 'tool',
render: { json() {}, lines() {} },
},
});
const items = [];
for await (const it of result.output) items.push(it);
assert.deepEqual(items, [{ ok: true, legacy: true, echo: { hello: "world" } }]);
assert.deepEqual(items, [{ ok: true, legacy: true, echo: { hello: 'world' } }]);
} finally {
server.close();
}

View File

@ -1,37 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
function runLobster(args: string[], opts?: { env?: Record<string, string | undefined> }) {
const res = spawnSync(process.execPath, [path.join("bin", "lobster.js"), ...args], {
cwd: path.resolve("."),
env: { ...process.env, ...(opts?.env ?? undefined) },
encoding: "utf8",
});
return res;
}
test("cli: run --file passes --args-json into workflow args", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cli-"));
const filePath = path.join(tmpDir, "workflow.lobster");
// Print both template-substituted arg and env-injected arg (LOBSTER_ARG_TASK)
// so we catch regressions in either path.
const workflow = `name: test\nargs:\n task:\n default: ""\nsteps:\n - id: s\n command: >\n node -e "process.stdout.write(JSON.stringify({task: '\${task}', env: process.env.LOBSTER_ARG_TASK}))"\n`;
await fsp.writeFile(filePath, workflow, "utf8");
const res = runLobster(["run", "--file", filePath, "--args-json", '{"task":"test"}']);
assert.equal(
res.status,
0,
`expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
const parsed = JSON.parse(String(res.stdout).trim());
assert.deepEqual(parsed, [{ task: "test", env: "test" }]);
});

View File

@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createDefaultRegistry } from "../src/commands/registry.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDefaultRegistry } from '../src/commands/registry.js';
function streamOf(items) {
return (async function* () {
@ -8,10 +8,10 @@ function streamOf(items) {
})();
}
test("commands.list returns command inventory including stdlib + workflows", async () => {
test('commands.list returns command inventory including stdlib + workflows', async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("commands.list");
assert.ok(cmd, "commands.list should be registered");
const cmd = registry.get('commands.list');
assert.ok(cmd, 'commands.list should be registered');
const res = await cmd.run({
input: streamOf([]),
@ -22,7 +22,7 @@ test("commands.list returns command inventory including stdlib + workflows", asy
stderr: process.stderr,
env: process.env,
registry,
mode: "tool",
mode: 'tool',
render: { json() {}, lines() {} },
},
});
@ -33,15 +33,14 @@ test("commands.list returns command inventory including stdlib + workflows", asy
const names = items.map((x) => x.name).sort();
// A couple representative commands we always expect.
assert.ok(names.includes("exec"));
assert.ok(names.includes("json"));
assert.ok(names.includes("llm.invoke"));
assert.ok(names.includes("workflows.list"));
assert.ok(names.includes("commands.list"));
assert.ok(names.includes('exec'));
assert.ok(names.includes('json'));
assert.ok(names.includes('workflows.list'));
assert.ok(names.includes('commands.list'));
const self = items.find((x) => x.name === "commands.list");
const self = items.find((x) => x.name === 'commands.list');
assert.ok(self);
assert.equal(typeof self.description, "string");
assert.equal(typeof self.description, 'string');
assert.ok(self.description.length > 0);
// Schema should be present for commands that declare it.
assert.ok(self.argsSchema);

View File

@ -1,139 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cond-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
},
});
}
test("condition > works with numbers", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({count:5}))"' },
{ id: "check", command: 'echo "big"', when: "$data.json.count > 3" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["big\n"]);
});
test("condition > skips when false", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({count:1}))"' },
{ id: "check", command: 'echo "big"', when: "$data.json.count > 3" },
{ id: "fallback", command: 'echo "small"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["small\n"]);
});
test("condition < works", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:2}))"' },
{ id: "check", command: 'echo "low"', when: "$data.json.val < 10" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["low\n"]);
});
test("condition >= works at boundary", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:5}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val >= 5" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["yes\n"]);
});
test("condition <= works at boundary", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:5}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val <= 5" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["yes\n"]);
});
test("comparison operators combine with boolean operators", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({a:5,b:20}))"' },
{ id: "check", command: 'echo "in range"', when: "$data.json.a >= 1 && $data.json.b < 100" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["in range\n"]);
});
test("comparison with non-numeric string returns false", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:\\"hello\\"}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val > 3" },
{ id: "fallback", command: 'echo "no"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["no\n"]);
});
test("comparison rejects boolean as non-numeric", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:true}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val > 0" },
{ id: "fallback", command: 'echo "no"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["no\n"]);
});
test("comparison rejects null as non-numeric", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:null}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val >= 0" },
{ id: "fallback", command: 'echo "no"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["no\n"]);
});
test("existing == and != still work with new operators", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({status:\\"ok\\"}))"' },
{ id: "check", command: 'echo "good"', when: '$data.json.status == "ok"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["good\n"]);
});

View File

@ -1,194 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import path from "node:path";
import os from "node:os";
import { resumeToolRequest, runToolRequest } from "../src/core/index.js";
function createDirectAdapter(resultText: string) {
const calls: Array<Record<string, unknown>> = [];
return {
calls,
adapter: {
source: "test",
async invoke({ payload }: { payload: Record<string, unknown> }) {
calls.push(payload);
return {
ok: true,
result: {
runId: "adapter_1",
model: "test/model",
prompt: payload.prompt,
status: "completed",
output: {
format: "json",
text: resultText,
data: JSON.parse(resultText),
},
},
};
},
},
};
}
test("runToolRequest executes pipeline with injected llm adapter", async () => {
const { adapter, calls } = createDirectAdapter('{"recommendation":"no jacket"}');
const envelope = await runToolRequest({
pipeline:
'exec --json=true node -e "process.stdout.write(JSON.stringify({location:\'Phoenix\',temp_f:73.8}))" | llm.invoke --provider pi --prompt "Should I wear a jacket?" --disable-cache',
ctx: {
env: {
...process.env,
LOBSTER_LLM_PROVIDER: "pi",
LOBSTER_LLM_MODEL: "test/model",
},
llmAdapters: {
pi: adapter,
},
},
});
assert.equal(envelope.ok, true);
assert.equal(envelope.status, "ok");
assert.equal(envelope.output?.length, 1);
assert.equal((envelope.output![0] as any).output.data.recommendation, "no jacket");
assert.equal(calls.length, 1);
assert.equal((calls[0] as any).model, "test/model");
});
test("resumeToolRequest completes approval-gated workflow with injected llm adapter", async () => {
const { adapter, calls } = createDirectAdapter('{"recommendation":"no","reason":"warm"}');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-core-tool-runtime-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify(
{
steps: [
{
id: "fetch",
run: "node -e \"process.stdout.write(JSON.stringify({location:'Phoenix',temp_f:73.8}))\"",
},
{
id: "confirm",
approval: "Want jacket advice?",
stdin: "$fetch.json",
},
{
id: "advice",
pipeline: 'llm.invoke --provider pi --prompt "Return JSON." --disable-cache',
stdin: "$fetch.json",
when: "$confirm.approved",
},
],
},
null,
2,
),
"utf8",
);
const env = {
...process.env,
LOBSTER_STATE_DIR: path.join(tmpDir, "state"),
LOBSTER_LLM_PROVIDER: "pi",
LOBSTER_LLM_MODEL: "test/model",
};
const first = await runToolRequest({
filePath,
ctx: {
cwd: tmpDir,
env,
llmAdapters: { pi: adapter },
},
});
assert.equal(first.ok, true);
assert.equal(first.status, "needs_approval");
assert.ok(first.requiresApproval?.resumeToken);
const resumed = await resumeToolRequest({
token: first.requiresApproval?.resumeToken ?? "",
approved: true,
ctx: {
cwd: tmpDir,
env,
llmAdapters: { pi: adapter },
},
});
assert.equal(resumed.ok, true);
assert.equal(resumed.status, "ok");
assert.equal((resumed.output![0] as any).output.data.reason, "warm");
assert.equal(calls.length, 1);
});
test("runToolRequest/resumeToolRequest handles needs_input workflow pauses", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-core-tool-input-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify(
{
steps: [
{
id: "draft",
run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
},
{
id: "review",
input: {
prompt: "Review draft?",
responseSchema: {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
},
},
},
{
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION,subject:process.env.SUBJECT}))"',
env: {
DECISION: "$review.response.decision",
SUBJECT: "$review.subject.text",
},
},
],
},
null,
2,
),
"utf8",
);
const env = {
...process.env,
LOBSTER_STATE_DIR: path.join(tmpDir, "state"),
};
const first = await runToolRequest({
filePath,
ctx: { cwd: tmpDir, env },
});
assert.equal(first.ok, true);
assert.equal(first.status, "needs_input");
assert.deepEqual(first.requiresInput?.subject, { text: "hello" });
assert.ok(first.requiresInput?.resumeToken);
const resumed = await resumeToolRequest({
token: first.requiresInput?.resumeToken ?? "",
response: { decision: "approve" },
ctx: { cwd: tmpDir, env },
});
assert.equal(resumed.ok, true);
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ decision: "approve", subject: "hello" }]);
});

View File

@ -1,147 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { CostTracker } from "../src/core/cost_tracker.js";
import { runWorkflowFile } from "../src/workflows/file.js";
test("CostTracker records usage and computes totals", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "gpt-4o", { inputTokens: 1000, outputTokens: 500 });
const summary = tracker.getSummary();
assert.equal(summary.totalInputTokens, 1000);
assert.equal(summary.totalOutputTokens, 500);
assert.equal(summary.estimatedCostUsd, 0.0075);
assert.equal(summary.byStep.length, 1);
assert.equal(summary.byStep[0].stepId, "step1");
});
test("CostTracker handles OpenAI token field names", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "gpt-4o", { prompt_tokens: 1000, completion_tokens: 500 });
const summary = tracker.getSummary();
assert.equal(summary.totalInputTokens, 1000);
assert.equal(summary.totalOutputTokens, 500);
});
test("CostTracker uses zero cost for unknown models", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "unknown-model", { inputTokens: 1000, outputTokens: 500 });
const summary = tracker.getSummary();
assert.equal(summary.estimatedCostUsd, 0);
});
test("CostTracker supports custom pricing from env json", () => {
const pricing = CostTracker.parsePricingFromEnv({
LOBSTER_LLM_PRICING_JSON: '{"my-model":{"input":1.0,"output":2.0}}',
});
const tracker = new CostTracker(pricing);
tracker.recordUsage("step1", "my-model", { inputTokens: 1_000_000, outputTokens: 1_000_000 });
assert.equal(tracker.getSummary().estimatedCostUsd, 3);
});
test("CostTracker checkLimit throws when action=stop and limit exceeded", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "gpt-4o", { inputTokens: 10_000_000, outputTokens: 10_000_000 });
assert.throws(() => tracker.checkLimit({ max_usd: 0.01, action: "stop" }), /Cost limit exceeded/);
});
test("CostTracker checkLimit warns when action=warn and limit exceeded", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "gpt-4o", { inputTokens: 10_000_000, outputTokens: 10_000_000 });
const stderr = new PassThrough();
let out = "";
stderr.on("data", (d: Buffer | string) => {
out += String(d);
});
tracker.checkLimit({ max_usd: 0.01, action: "warn" }, stderr);
assert.match(out, /\[WARN\] Cost/);
});
async function runWorkflow(workflow: unknown, envOverride?: Record<string, string>) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const stderr = new PassThrough();
let stderrOutput = "";
stderr.on("data", (d: Buffer | string) => {
stderrOutput += String(d);
});
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir, ...(envOverride ?? {}) },
mode: "tool",
},
});
return { result, stderrOutput };
}
test("workflow result includes _meta.cost when usage is present", async () => {
const { result } = await runWorkflow({
steps: [
{
id: "llm",
command:
"node -e \"process.stdout.write(JSON.stringify({model:'gpt-4o',usage:{inputTokens:100,outputTokens:50},output:{text:'hi'}}))\"",
},
],
});
assert.equal(result.status, "ok");
assert.ok(result._meta?.cost);
assert.equal(result._meta!.cost!.totalInputTokens, 100);
assert.equal(result._meta!.cost!.totalOutputTokens, 50);
assert.equal(result._meta!.cost!.byStep[0].model, "gpt-4o");
});
test("workflow result omits _meta.cost when no usage exists", async () => {
const { result } = await runWorkflow({
steps: [{ id: "plain", command: 'echo "hello"' }],
});
assert.equal(result.status, "ok");
assert.equal(result._meta, undefined);
});
test("cost_limit warn logs warning and continues", async () => {
const { result, stderrOutput } = await runWorkflow({
cost_limit: { max_usd: 0.00001, action: "warn" },
steps: [
{
id: "llm",
command:
"node -e \"process.stdout.write(JSON.stringify({model:'gpt-4o',usage:{inputTokens:1000,outputTokens:1000}}))\"",
},
{ id: "after", command: "echo done" },
],
});
assert.equal(result.status, "ok");
assert.match(stderrOutput, /\[WARN\] Cost/);
assert.deepEqual(result.output, ["done\n"]);
});
test("cost_limit stop throws when exceeded", async () => {
await assert.rejects(
() =>
runWorkflow({
cost_limit: { max_usd: 0.00001, action: "stop" },
steps: [
{
id: "llm",
command:
"node -e \"process.stdout.write(JSON.stringify({model:'gpt-4o',usage:{inputTokens:1000,outputTokens:1000}}))\"",
},
],
}).then((x) => x.result),
/Cost limit exceeded/,
);
});

View File

@ -1,9 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { parsePipeline } from "../src/parser.js";
import { runPipeline } from '../src/runtime.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { parsePipeline } from '../src/parser.js';
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
@ -15,36 +15,34 @@ async function run(pipelineText: string, input: any[]) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
mode: 'tool',
input: (async function* () { for (const x of input) yield x; })(),
});
return res.items;
}
test("dedupe removes duplicate primitives (stable)", async () => {
const out = await run("dedupe", [1, 2, 1, 3, 2]);
test('dedupe removes duplicate primitives (stable)', async () => {
const out = await run('dedupe', [1, 2, 1, 3, 2]);
assert.deepEqual(out, [1, 2, 3]);
});
test("dedupe supports --key", async () => {
test('dedupe supports --key', async () => {
const input = [
{ id: "a", v: 1 },
{ id: "b", v: 2 },
{ id: "a", v: 3 },
{ id: 'a', v: 1 },
{ id: 'b', v: 2 },
{ id: 'a', v: 3 },
];
const out = await run("dedupe --key id", input);
const out = await run('dedupe --key id', input);
assert.deepEqual(out, [input[0], input[1]]);
});
test("dedupe treats undefined keys as a key value", async () => {
test('dedupe treats undefined keys as a key value', async () => {
const input = [
{ id: undefined, v: 1 },
{ id: undefined, v: 2 },
{ id: "x", v: 3 },
{ id: 'x', v: 3 },
];
const out = await run("dedupe --key id", input);
const out = await run('dedupe --key id', input);
assert.equal(out.length, 2);
assert.equal(out[0].v, 1);
assert.equal(out[1].v, 3);

View File

@ -1,9 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtempSync } from "node:fs";
import { createDefaultRegistry } from "../src/commands/registry.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import { mkdtempSync } from 'node:fs';
import { createDefaultRegistry } from '../src/commands/registry.js';
function streamOf(items) {
return (async function* () {
@ -11,24 +11,16 @@ function streamOf(items) {
})();
}
test("diff.last reports changed on first run and not changed on same input", async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-"));
test('diff.last reports changed on first run and not changed on same input', async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-diff-'));
const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
const registry = createDefaultRegistry();
const cmd = registry.get("diff.last");
const cmd = registry.get('diff.last');
const first = await cmd.run({
input: streamOf([{ a: 1 }]),
args: { _: [], key: "k" },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
args: { _: [], key: 'k' },
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env, registry, mode: 'tool', render: { json() {}, lines() {} } },
});
const out1 = [];
for await (const it of first.output) out1.push(it);
@ -36,16 +28,8 @@ test("diff.last reports changed on first run and not changed on same input", asy
const second = await cmd.run({
input: streamOf([{ a: 1 }]),
args: { _: [], key: "k" },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
args: { _: [], key: 'k' },
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env, registry, mode: 'tool', render: { json() {}, lines() {} } },
});
const out2 = [];
for await (const it of second.output) out2.push(it);
@ -53,16 +37,8 @@ test("diff.last reports changed on first run and not changed on same input", asy
const third = await cmd.run({
input: streamOf([{ a: 2 }]),
args: { _: [], key: "k" },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
args: { _: [], key: 'k' },
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env, registry, mode: 'tool', render: { json() {}, lines() {} } },
});
const out3 = [];
for await (const it of third.output) out3.push(it);

View File

@ -1,19 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
test("doctor returns tool-mode ok with version", () => {
const bin = path.join(process.cwd(), "bin", "lobster.js");
const res = spawnSync("node", [bin, "doctor"], {
encoding: "utf8",
env: { ...process.env, LOBSTER_STATE_DIR: path.join(process.cwd(), ".tmp-test-state") },
test('doctor returns tool-mode ok with version', () => {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
const res = spawnSync('node', [bin, 'doctor'], {
encoding: 'utf8',
env: { ...process.env, LOBSTER_STATE_DIR: path.join(process.cwd(), '.tmp-test-state') },
});
assert.equal(res.status, 0);
const out = JSON.parse(res.stdout);
assert.equal(out.ok, true);
assert.equal(out.protocolVersion, 1);
assert.equal(out.status, "ok");
assert.equal(out.status, 'ok');
assert.equal(out.output[0].toolMode, true);
assert.ok(typeof out.output[0].version === "string");
assert.ok(typeof out.output[0].version === 'string');
});

View File

@ -1,618 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { runWorkflowFile } from "../src/workflows/file.js";
import { PassThrough } from "node:stream";
function runLobster(args: string[], opts?: { env?: Record<string, string | undefined> }) {
const res = spawnSync(process.execPath, [path.join("bin", "lobster.js"), ...args], {
cwd: path.resolve("."),
env: { ...process.env, ...(opts?.env ?? undefined) },
encoding: "utf8",
});
return res;
}
function createStreams() {
const stdout = new PassThrough();
const stderr = new PassThrough();
let stdoutData = "";
let stderrData = "";
stdout.setEncoding("utf8");
stderr.setEncoding("utf8");
stdout.on("data", (chunk) => {
stdoutData += chunk;
});
stderr.on("data", (chunk) => {
stderrData += chunk;
});
return {
stdout,
stderr,
getStdout: () => stdoutData,
getStderr: () => stderrData,
};
}
test("dry-run of a 3-step workflow file (shell steps)", async () => {
const workflow = {
name: "test-dry-run",
args: { url: { default: "https://example.com" } },
steps: [
{ id: "fetch-data", run: "curl ${url}" },
{ id: "transform", run: 'jq ".items"' },
{ id: "upload", run: "curl -X POST https://api.example.com" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, []);
const output = getStderr();
assert.match(output, /\[DRY RUN\]/);
assert.match(output, /Would execute 3 steps/);
assert.match(output, /fetch-data\s+\[shell\]/);
assert.match(output, /run: curl https:\/\/example\.com/);
assert.match(output, /transform\s+\[shell\]/);
assert.match(output, /upload\s+\[shell\]/);
});
test("dry-run of a workflow with an approval step", async () => {
const workflow = {
steps: [
{ id: "collect", run: "echo hello" },
{
id: "approve_step",
run: "echo check",
approval: "Proceed with deployment?",
},
{ id: "deploy", run: "echo deploying" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-approval-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "tool",
dryRun: true,
},
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, []);
const output = getStderr();
assert.match(output, /approve_step\s+\[shell\]/);
assert.match(output, /\[approval required\]/);
});
test("dry-run of a workflow with a conditional step that would be skipped", async () => {
const workflow = {
steps: [
{ id: "gate", run: "echo gate" },
{
id: "conditional",
run: "echo conditional",
condition: false,
},
{ id: "final", run: "echo final" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-cond-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.match(output, /conditional\s+\[skipped — condition: false\]/);
assert.match(output, /gate\s+\[shell\]/);
assert.match(output, /final\s+\[shell\]/);
});
test("dry-run of an inline pipeline string via CLI", async () => {
const res = runLobster([
"run",
"--dry-run",
'exec --json "echo [1,2,3]" | where active=true | table',
]);
assert.equal(
res.status,
0,
`expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
assert.match(res.stderr, /\[DRY RUN\] Pipeline/);
assert.match(res.stderr, /exec/);
assert.match(res.stderr, /where/);
assert.match(res.stderr, /table/);
// stdout must be clean — no JSON array leaking through
assert.equal(res.stdout.trim(), "", `expected empty stdout, got: ${res.stdout}`);
});
test("dry-run exits 0 on valid input", async () => {
const workflow = {
steps: [{ id: "step1", run: "echo hello" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-exit-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const res = runLobster(["run", "--dry-run", "--file", filePath]);
assert.equal(
res.status,
0,
`expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
assert.match(res.stderr, /\[DRY RUN\]/);
assert.match(res.stderr, /step1/);
// stdout must be clean — no JSON output
assert.equal(res.stdout.trim(), "", `expected empty stdout, got: ${res.stdout}`);
});
test("normal run still works (no regression)", async () => {
const workflow = {
steps: [
{
id: "greet",
command: "node -e \"process.stdout.write(JSON.stringify({msg:'hello'}))\"",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-regression-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const res = runLobster(["run", "--file", filePath]);
assert.equal(
res.status,
0,
`expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
const parsed = JSON.parse(res.stdout.trim());
assert.deepEqual(parsed, [{ msg: "hello" }]);
});
test("dry-run approval step sets approved:true so downstream conditions evaluate correctly", async () => {
const workflow = {
steps: [
{ id: "gate", run: "echo gate", approval: "Continue?" },
{ id: "post-approval", run: "echo done", when: "$gate.approved" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-approvcond-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
// post-approval step should NOT be marked as skipped — approval is modeled as granted
assert.doesNotMatch(output, /post-approval.*skipped/);
assert.match(output, /post-approval\s+\[shell\]/);
});
test("dry-run workflow with pipeline step", async () => {
const registry = createDefaultRegistry();
const workflow = {
steps: [
{ id: "fetch", run: "echo hello" },
{ id: "process", pipeline: 'exec --json "echo [1]" | head 1' },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-pipeline-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
registry,
dryRun: true,
},
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, []);
const output = getStderr();
assert.match(output, /fetch\s+\[shell\]/);
assert.match(output, /process\s+\[pipeline\]/);
assert.match(output, /pipeline: exec --json "echo \[1\]" \| head 1/);
});
test("dry-run throws on stdin ref to non-existent step", async () => {
const workflow = {
steps: [
{ id: "gen", run: "echo hello" },
{ id: "consumer", run: "echo done", stdin: "$missing.stdout" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-stdin-bad-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr } = createStreams();
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
}),
/Unknown step reference: missing\.stdout/,
);
});
test("dry-run annotates valid stdin step refs as unknown at plan time", async () => {
const workflow = {
steps: [
{ id: "gen", run: "echo hello" },
{ id: "consumer", run: "echo done", stdin: "$gen.stdout" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-stdin-valid-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
assert.match(getStderr(), /output unknown at plan time/);
});
test("dry-run preserves step output refs in shell commands instead of collapsing them to empty strings", async () => {
const workflow = {
steps: [
{ id: "fetch", run: "echo hello" },
{ id: "render", run: "echo A=[$fetch.stdout] B=[$fetch.json]" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-shellrefs-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.match(output, /run: echo A=\[\$fetch\.stdout\] B=\[\$fetch\.json\]/);
assert.match(output, /\[contains step output refs — unknown at plan time\]/);
assert.doesNotMatch(output, /run: echo A=\[\] B=\[\]/);
});
test("dry-run still resolves approved refs in shell commands", async () => {
const workflow = {
steps: [
{ id: "gate", run: "echo ok", approval: "Continue?" },
{ id: "render", run: "echo approved=$gate.approved" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-approvedref-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.match(output, /run: echo approved=true/);
assert.doesNotMatch(output, /approved=\$gate\.approved/);
});
test("dry-run throws on pipeline step with unknown command", async () => {
const registry = createDefaultRegistry();
const workflow = {
steps: [{ id: "step1", pipeline: "not_a_real_command | head 1" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-badcmd-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr } = createStreams();
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
registry,
dryRun: true,
},
}),
/unknown command: not_a_real_command/,
);
});
test("dry-run flag is consumed regardless of position in argv", async () => {
// --dry-run before pipeline tokens (canonical form)
const before = runLobster(["run", "--dry-run", 'exec --json "echo [1]"']);
assert.equal(before.status, 0);
assert.match(before.stderr, /\[DRY RUN\]/);
assert.equal(before.stdout.trim(), "");
// --dry-run after positional file arg: lobster run workflow.lobster --dry-run
// This is the primary use-case that was previously broken.
});
test("dry-run flag after positional file arg activates dry-run", async () => {
const workflow = {
steps: [{ id: "greet", run: "echo hello" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-test-"));
const filePath = path.join(tmpDir, "test.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow), "utf8");
// Flag comes AFTER the positional file argument — must still activate dry-run.
const res = runLobster(["run", filePath, "--dry-run"]);
assert.equal(res.status, 0, `expected exit 0\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(
res.stderr,
/\[DRY RUN\]/,
"expected [DRY RUN] in stderr when flag is after file path",
);
assert.equal(res.stdout.trim(), "", "expected no stdout in dry-run mode");
await fsp.rm(tmpDir, { recursive: true, force: true });
});
test("dry-run flag still activates after positional workflow file when other Lobster flags are present", async () => {
const workflow = {
args: { name: { default: "hello" } },
steps: [{ id: "greet", run: "echo ${name}" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-test-args-"));
const filePath = path.join(tmpDir, "test.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow), "utf8");
const res = runLobster(["run", filePath, "--args-json", '{"name":"world"}', "--dry-run"]);
assert.equal(res.status, 0, `expected exit 0\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stderr, /\[DRY RUN\]/);
assert.match(res.stderr, /run: echo world/);
assert.equal(res.stdout.trim(), "");
await fsp.rm(tmpDir, { recursive: true, force: true });
});
test("command-level --dry-run in the first stage is not stolen by Lobster dry-run parsing", () => {
const res = runLobster([
"run",
"openclaw.invoke",
"--tool",
"message",
"--action",
"send",
"--dry-run",
]);
assert.notEqual(
res.status,
0,
"expected command execution to fail instead of Lobster dry-run exiting 0",
);
assert.doesNotMatch(res.stderr, /\[DRY RUN\]/);
assert.match(res.stderr, /requires --url or OPENCLAW_URL/);
});
test("dry-run allows pipeline stage names that still depend on prior step output", async () => {
const registry = createDefaultRegistry();
const workflow = {
steps: [
{ id: "planner", run: "echo openclaw.invoke" },
{ id: "call", pipeline: "$planner.stdout --tool message --action send" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-dynamic-stage-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
registry,
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.match(output, /pipeline: \$planner\.stdout --tool message --action send/);
assert.match(output, /\[command validation deferred — stage name depends on step output\]/);
});
test("dry-run does not eat shell variables that only resemble step refs", async () => {
const workflow = {
steps: [{ id: "shell_vars", run: "echo $HOME.json $PATH.stdout" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-shell-vars-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const res = runLobster(["run", "--dry-run", filePath]);
assert.equal(res.status, 0);
assert.match(res.stderr, /echo \$HOME\.json \$PATH\.stdout/);
});
test("dry-run evaluates compound conditions with approvals, input placeholders, and parentheses", async () => {
const workflow = {
steps: [
{ id: "gate", run: "echo ok", approval: "Continue?" },
{
id: "review",
input: {
prompt: "Review?",
responseSchema: {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
},
},
},
{
id: "deploy",
run: "echo deploy",
condition: "$gate.approved && ($review.response.pending == true || $review.skipped)",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-compound-cond-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.doesNotMatch(output, /deploy.*skipped/);
assert.match(output, /deploy\s+\[shell\]/);
});

View File

@ -139,7 +139,7 @@ test("email.triage --llm uses llm_task.invoke to draft replies (and can emit dra
bodyLog.push(parsed);
res.writeHead(200, { "content-type": "application/json" });
// OpenClaw tool router envelope -> llm-task tool envelope
// Clawdbot tool router envelope -> llm-task tool envelope
res.end(
JSON.stringify({
ok: true,
@ -178,9 +178,7 @@ test("email.triage --llm uses llm_task.invoke to draft replies (and can emit dra
})();
const res1 = await runPipeline({
pipeline: [
{ name: "email.triage", args: { llm: true, model: "claude-test", limit: 20 }, raw: "" },
],
pipeline: [{ name: "email.triage", args: { llm: true, model: "claude-test", limit: 20 }, raw: "" }],
registry,
input: input1,
stdin: process.stdin,
@ -239,88 +237,3 @@ test("email.triage --llm uses llm_task.invoke to draft replies (and can emit dra
await closeServer(server);
}
});
test("email.triage --llm honors OPENCLAW_URL (not just CLAWD_URL)", async () => {
const registry = createDefaultRegistry();
const cacheDir = await mkdtemp(join(tmpdir(), "lobster-cache-"));
const emails = [
{
id: "m1",
threadId: "t1",
from: "Alice <alice@example.com>",
subject: "Quick question",
date: "2026-01-22T07:00:00Z",
snippet: "Hey, can you take a look?",
labels: ["INBOX", "UNREAD"],
},
];
let callCount = 0;
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end("not found");
return;
}
callCount++;
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
ok: true,
result: {
runId: "triage_openclaw_url",
output: {
data: {
decisions: [
{
id: "m1",
category: "needs_reply",
reply: { body: "Absolutely — I can help." },
},
],
},
},
},
},
}),
);
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
try {
const input = (async function* () {
for (const e of emails) yield e;
})();
const result = await runPipeline({
pipeline: [{ name: "email.triage", args: { llm: true, limit: 20 }, raw: "" }],
registry,
input,
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: {
...process.env,
OPENCLAW_URL: `http://127.0.0.1:${port}`,
LOBSTER_CACHE_DIR: cacheDir,
LLM_TASK_FORCE_REFRESH: "1",
},
mode: "tool",
} as any);
assert.equal(callCount, 1);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].mode, "llm");
assert.equal(result.items[0].drafts.length, 1);
} finally {
await rm(cacheDir, { recursive: true, force: true });
await closeServer(server);
}
});

View File

@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createDefaultRegistry } from "../src/commands/registry.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDefaultRegistry } from '../src/commands/registry.js';
function streamOf(items) {
return (async function* () {
@ -8,9 +8,9 @@ function streamOf(items) {
})();
}
test("exec --stdin jsonl feeds pipeline input to subprocess", async () => {
test('exec --stdin jsonl feeds pipeline input to subprocess', async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("exec");
const cmd = registry.get('exec');
const nodeScript = [
"let d='';",
@ -19,13 +19,13 @@ test("exec --stdin jsonl feeds pipeline input to subprocess", async () => {
" const lines=d.trim().split('\\n').filter(Boolean);",
" console.log(JSON.stringify(lines));",
"});",
].join("");
].join('');
const result = await cmd.run({
input: streamOf([{ a: 1 }, { a: 2 }]),
args: {
_: ["node", "-e", nodeScript],
stdin: "jsonl",
_: ['node', '-e', nodeScript],
stdin: 'jsonl',
json: true,
},
ctx: {
@ -34,7 +34,7 @@ test("exec --stdin jsonl feeds pipeline input to subprocess", async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: "human",
mode: 'human',
render: { json() {}, lines() {} },
},
});

View File

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -9,17 +9,17 @@ const __dirname = dirname(__filename);
const argv = process.argv.slice(2);
// Minimal mock for `gog gmail search` and `gog gmail send`.
if (argv[0] === "gmail" && argv[1] === "search") {
const data = readFileSync(join(__dirname, "gog_gmail_search.json"), "utf8");
if (argv[0] === 'gmail' && argv[1] === 'search') {
const data = readFileSync(join(__dirname, 'gog_gmail_search.json'), 'utf8');
process.stdout.write(data);
process.exit(0);
}
if (argv[0] === "gmail" && argv[1] === "send") {
if (argv[0] === 'gmail' && argv[1] === 'send') {
// Echo a json success object.
process.stdout.write(JSON.stringify({ ok: true }));
process.exit(0);
}
process.stderr.write("mock-gog: unsupported args: " + argv.join(" ") + "\n");
process.stderr.write('mock-gog: unsupported args: ' + argv.join(' ') + '\n');
process.exit(2);

View File

@ -1,262 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { loadWorkflowFile, runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
registry: createDefaultRegistry(),
},
});
}
test("for_each iterates items and collects per-iteration results", async () => {
const result = await runWorkflow({
steps: [
{
id: "data",
command: 'node -e "process.stdout.write(JSON.stringify([{name:\\"a\\"},{name:\\"b\\"}]))"',
},
{
id: "loop",
for_each: "$data.json",
steps: [
{
id: "transform",
command:
'node -e "process.stdout.write(JSON.stringify({upper: process.env.NAME.toUpperCase()}))"',
env: { NAME: "$item.json.name" },
},
],
},
],
});
assert.equal(result.status, "ok");
const output = result.output as any[];
assert.equal(output.length, 2);
assert.equal(output[0].index, 0);
assert.equal(output[1].index, 1);
assert.equal(output[0].transform.upper, "A");
assert.equal(output[1].transform.upper, "B");
});
test("for_each supports custom item_var and index_var", async () => {
const result = await runWorkflow({
steps: [
{ id: "vals", command: 'node -e "process.stdout.write(JSON.stringify([10,20]))"' },
{
id: "loop",
for_each: "$vals.json",
item_var: "num",
index_var: "idx",
steps: [
{
id: "emit",
command:
'node -e "process.stdout.write(JSON.stringify({num:$num.json,idx:$idx.json}))"',
},
],
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [
{ num: 10, idx: 0, emit: { num: 10, idx: 0 } },
{ num: 20, idx: 1, emit: { num: 20, idx: 1 } },
]);
});
test("for_each throws when source is not an array", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({x:1}))"' },
{ id: "loop", for_each: "$data.json", steps: [{ id: "x", command: "echo hi" }] },
],
}),
/for_each: expected array/,
);
});
test("for_each validation rejects empty sub-step list", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [{ id: "loop", for_each: "$x.json", steps: [] }],
}),
"utf8",
);
await assert.rejects(
() => loadWorkflowFile(filePath),
/for_each requires a non-empty steps array/,
);
});
test("for_each validation rejects run/command/pipeline/workflow/parallel on loop step", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "loop",
for_each: "$x.json",
run: "echo no",
steps: [{ id: "s", command: "echo hi" }],
},
],
}),
"utf8",
);
await assert.rejects(
() => loadWorkflowFile(filePath),
/for_each cannot also define run, command, pipeline, workflow, or parallel/,
);
});
test("for_each validation rejects approval/input in sub-steps", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "loop",
for_each: "$x.json",
steps: [{ id: "s", command: "echo hi", approval: true }],
},
],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /cannot contain approval or input/);
});
test("for_each validation rejects duplicate sub-step ids", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "loop",
for_each: "$x.json",
steps: [
{ id: "dup", command: "echo a" },
{ id: "dup", command: "echo b" },
],
},
],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /duplicate for_each sub-step id/);
});
test("for_each validation rejects item_var/index_var collisions", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "loop",
for_each: "$x.json",
item_var: "x",
index_var: "x",
steps: [{ id: "s", command: "echo hi" }],
},
],
}),
"utf8",
);
await assert.rejects(
() => loadWorkflowFile(filePath),
/item_var and index_var cannot be the same/,
);
});
test("for_each pause_ms and batch_size are accepted and executable", async () => {
const result = await runWorkflow({
steps: [
{ id: "vals", command: 'node -e "process.stdout.write(JSON.stringify([1,2,3]))"' },
{
id: "loop",
for_each: "$vals.json",
batch_size: 2,
pause_ms: 10,
steps: [
{ id: "emit", command: 'node -e "process.stdout.write(JSON.stringify({v:$item.json}))"' },
],
},
],
});
assert.equal(result.status, "ok");
assert.equal((result.output as any[]).length, 3);
});
test("for_each dry-run renders loop structure", async () => {
const workflow = {
steps: [
{ id: "vals", command: 'node -e "process.stdout.write(JSON.stringify([1,2]))"' },
{
id: "loop",
for_each: "$vals.json",
batch_size: 2,
steps: [{ id: "emit", command: "echo hi" }],
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const stderr = new PassThrough();
let out = "";
stderr.on("data", (d: Buffer | string) => {
out += String(d);
});
await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
dryRun: true,
registry: createDefaultRegistry(),
},
});
assert.match(out, /\[for_each\]/);
assert.match(out, /sub-steps: 1/);
assert.match(out, /batch_size: 2/);
});

View File

@ -1,21 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildPrChangeSummary } from "../src/workflows/github_pr_monitor.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildPrChangeSummary } from '../src/workflows/github_pr_monitor.js';
function formatLikeWorkflow({ repo, pr, after, before }) {
const summary = buildPrChangeSummary(before, after);
const fields = summary.changedFields.length ? ` (${summary.changedFields.join(", ")})` : "";
const title = after?.title ? `: ${after.title}` : "";
const url = after?.url ? ` ${after.url}` : "";
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, " ").trim();
const fields = summary.changedFields.length ? ` (${summary.changedFields.join(', ')})` : '';
const title = after?.title ? `: ${after.title}` : '';
const url = after?.url ? ` ${after.url}` : '';
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, ' ').trim();
}
test("notify message includes repo/pr and changed fields", () => {
const before = { number: 1, title: "A", url: "u", state: "OPEN", updatedAt: "t1" };
const after = { ...before, title: "B", updatedAt: "t2" };
test('notify message includes repo/pr and changed fields', () => {
const before = { number: 1, title: 'A', url: 'u', state: 'OPEN', updatedAt: 't1' };
const after = { ...before, title: 'B', updatedAt: 't2' };
const msg = formatLikeWorkflow({ repo: "o/r", pr: 1, before, after });
assert.ok(msg.includes("o/r#1"));
assert.ok(msg.includes("title"));
assert.ok(msg.includes("updatedAt"));
const msg = formatLikeWorkflow({ repo: 'o/r', pr: 1, before, after });
assert.ok(msg.includes('o/r#1'));
assert.ok(msg.includes('title'));
assert.ok(msg.includes('updatedAt'));
});

View File

@ -1,45 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildPrChangeSummary } from "../src/workflows/github_pr_monitor.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildPrChangeSummary } from '../src/workflows/github_pr_monitor.js';
const build = buildPrChangeSummary as any;
test("buildPrChangeSummary reports all fields on first snapshot", () => {
test('buildPrChangeSummary reports all fields on first snapshot', () => {
const after = {
number: 1,
title: "A",
url: "u",
state: "OPEN",
title: 'A',
url: 'u',
state: 'OPEN',
isDraft: false,
mergeable: "MERGEABLE",
reviewDecision: "REVIEW_REQUIRED",
updatedAt: "t1",
baseRefName: "main",
headRefName: "feat",
mergeable: 'MERGEABLE',
reviewDecision: 'REVIEW_REQUIRED',
updatedAt: 't1',
baseRefName: 'main',
headRefName: 'feat',
};
const res = build(null, after);
assert.ok(res.changedFields.length > 0);
assert.equal(res.changes.title.to, "A");
assert.equal(res.changes.title.to, 'A');
});
test("buildPrChangeSummary only includes changed fields", () => {
test('buildPrChangeSummary only includes changed fields', () => {
const before = {
number: 1,
title: "A",
url: "u",
state: "OPEN",
title: 'A',
url: 'u',
state: 'OPEN',
isDraft: false,
mergeable: "MERGEABLE",
mergeable: 'MERGEABLE',
reviewDecision: null,
updatedAt: "t1",
baseRefName: "main",
headRefName: "feat",
updatedAt: 't1',
baseRefName: 'main',
headRefName: 'feat',
};
const after = { ...before, title: "B", updatedAt: "t2" };
const after = { ...before, title: 'B', updatedAt: 't2' };
const res = build(before, after);
assert.deepEqual(res.changedFields.sort(), ["title", "updatedAt"].sort());
assert.equal(res.changes.title.from, "A");
assert.equal(res.changes.title.to, "B");
assert.deepEqual(res.changedFields.sort(), ['title', 'updatedAt'].sort());
assert.equal(res.changes.title.from, 'A');
assert.equal(res.changes.title.to, 'B');
});

View File

@ -1,9 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { parsePipeline } from "../src/parser.js";
import { runPipeline } from '../src/runtime.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { parsePipeline } from '../src/parser.js';
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
@ -15,37 +15,33 @@ async function run(pipelineText: string, input: any[]) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
mode: 'tool',
input: (async function* () { for (const x of input) yield x; })(),
});
return res.items;
}
test("groupBy groups items by key and preserves group order", async () => {
test('groupBy groups items by key and preserves group order', async () => {
const input = [
{ from: "a", id: 1 },
{ from: "b", id: 2 },
{ from: "a", id: 3 },
{ from: 'a', id: 1 },
{ from: 'b', id: 2 },
{ from: 'a', id: 3 },
];
const out = await run("groupBy --key from", input);
const out = await run('groupBy --key from', input);
assert.equal(out.length, 2);
assert.deepEqual(out[0].key, "a");
assert.deepEqual(
out[0].items.map((x: any) => x.id),
[1, 3],
);
assert.deepEqual(out[0].key, 'a');
assert.deepEqual(out[0].items.map((x: any) => x.id), [1, 3]);
assert.equal(out[0].count, 2);
assert.deepEqual(out[1].key, "b");
assert.deepEqual(out[1].key, 'b');
});
test("groupBy supports nested key paths", async () => {
const input = [{ user: { id: "u1" } }, { user: { id: "u2" } }, { user: { id: "u1" } }];
const out = await run("groupBy --key user.id", input);
assert.deepEqual(
out.map((g: any) => g.key),
["u1", "u2"],
);
test('groupBy supports nested key paths', async () => {
const input = [
{ user: { id: 'u1' } },
{ user: { id: 'u2' } },
{ user: { id: 'u1' } },
];
const out = await run('groupBy --key user.id', input);
assert.deepEqual(out.map((g: any) => g.key), ['u1', 'u2']);
assert.equal(out[0].count, 2);
});

View File

@ -1,185 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items: any[]) {
return (async function* () {
for (const item of items) yield item;
})();
}
async function collect(iterable: AsyncIterable<any>) {
const items = [];
for await (const item of iterable) items.push(item);
return items;
}
test("llm.invoke auto-detects OpenClaw provider and normalizes output", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("llm.invoke");
assert.ok(cmd, "llm.invoke should be registered");
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const bodyLog: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end("nope");
return;
}
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
const parsed = JSON.parse(buf || "{}");
bodyLog.push(parsed);
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
ok: true,
result: {
runId: "invoke_1",
model: parsed.args?.model,
prompt: parsed.args?.prompt,
output: { data: { summary: "hello" } },
},
},
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([{ kind: "text", text: "doc" }]),
args: {
_: [],
model: "claude-3-sonnet",
prompt: "Summarize",
},
ctx: baseCtx(
{ OPENCLAW_URL: `http://localhost:${port}`, LOBSTER_CACHE_DIR: cacheDir },
registry,
),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
assert.equal(items[0].kind, "llm.invoke");
assert.equal(items[0].source, "openclaw");
assert.equal(items[0].runId, "invoke_1");
assert.equal(items[0].output.data.summary, "hello");
assert.equal(bodyLog.length, 1);
assert.equal(bodyLog[0].tool, "llm-task");
assert.equal(bodyLog[0].args.prompt, "Summarize");
} finally {
await rm(cacheDir, { recursive: true, force: true });
await closeServer(server);
}
});
test("llm.invoke uses Pi adapter over local HTTP bridge", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("llm.invoke");
assert.ok(cmd);
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const requestLog: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/invoke") {
res.writeHead(404);
res.end("nope");
return;
}
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
const parsed = JSON.parse(buf || "{}");
requestLog.push(parsed);
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
runId: "pi_1",
model: parsed.model,
prompt: parsed.prompt,
output: {
format: "json",
text: '{"decision":"reply"}',
data: { decision: "reply" },
},
diagnostics: { adapter: "pi" },
},
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([{ kind: "text", text: "draft this" }]),
args: {
_: [],
provider: "pi",
prompt: "Decide",
"output-schema": '{"type":"object","required":["decision"]}',
},
ctx: baseCtx(
{
LOBSTER_PI_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
LOBSTER_LLM_MODEL: "anthropic/claude-sonnet-4-5",
LOBSTER_CACHE_DIR: cacheDir,
},
registry,
),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
assert.equal(items[0].kind, "llm.invoke");
assert.equal(items[0].source, "pi");
assert.equal(items[0].model, "anthropic/claude-sonnet-4-5");
assert.equal(items[0].output.data.decision, "reply");
assert.equal(requestLog.length, 1);
assert.equal(requestLog[0].prompt, "Decide");
assert.equal(requestLog[0].model, "anthropic/claude-sonnet-4-5");
assert.equal(requestLog[0].artifacts.length, 1);
} finally {
await rm(cacheDir, { recursive: true, force: true });
await closeServer(server);
}
});
function baseCtx(envOverrides: Record<string, string>, registry?: any) {
return {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, ...envOverrides },
registry: registry ?? null,
mode: "tool",
render: { json() {}, lines() {} },
};
}
async function closeServer(server: http.Server) {
if (!server.listening) return;
await new Promise<void>((resolve) => server.close(() => resolve()));
}

View File

@ -1,11 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createDefaultRegistry } from "../src/commands/registry.js";
import { createDefaultRegistry } from '../src/commands/registry.js';
function streamOf(items: any[]) {
return (async function* () {
@ -19,38 +19,38 @@ async function collect(iterable: AsyncIterable<any>) {
return items;
}
test("llm_task.invoke posts to /tools/invoke (clawd) and normalizes result", async () => {
test('llm_task.invoke posts to /tools/invoke (clawd) and normalizes result', async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("llm_task.invoke");
assert.ok(cmd, "llm_task.invoke should be registered");
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const cmd = registry.get('llm_task.invoke');
assert.ok(cmd, 'llm_task.invoke should be registered');
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const bodyLog: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
res.writeHead(404);
res.end("nope");
res.end('nope');
return;
}
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
const parsed = JSON.parse(buf || "{}");
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
const parsed = JSON.parse(buf || '{}');
bodyLog.push(parsed);
res.writeHead(200, { "content-type": "application/json" });
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
ok: true,
result: {
ok: true,
result: {
runId: "task_1",
runId: 'task_1',
model: parsed.args?.model,
prompt: parsed.args?.prompt,
output: {
text: "done",
data: { summary: "hello world" },
text: 'done',
data: { summary: 'hello world' },
},
usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 },
},
@ -62,39 +62,36 @@ test("llm_task.invoke posts to /tools/invoke (clawd) and normalizes result", asy
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const port = typeof addr === 'object' && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([{ kind: "text", text: "doc" }]),
input: streamOf([{ kind: 'text', text: 'doc' }]),
args: {
_: [],
token: "test-token",
model: "claude-3-sonnet",
prompt: "Summarize",
token: 'test-token',
model: 'claude-3-sonnet',
prompt: 'Summarize',
},
ctx: baseCtx(
{ LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` },
registry,
),
ctx: baseCtx({ LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
const payload = items[0];
assert.equal(payload.kind, "llm_task.invoke");
assert.equal(payload.runId, "task_1");
assert.equal(payload.output.data.summary, "hello world");
assert.equal(payload.model, "claude-3-sonnet");
assert.equal(payload.source, "clawd");
assert.equal(payload.kind, 'llm_task.invoke');
assert.equal(payload.runId, 'task_1');
assert.equal(payload.output.data.summary, 'hello world');
assert.equal(payload.model, 'claude-3-sonnet');
assert.equal(payload.source, 'clawd');
assert.equal(payload.cached, false);
assert.ok(payload.cacheKey);
assert.equal(bodyLog.length, 1);
assert.equal(bodyLog[0].tool, "llm-task");
assert.equal(bodyLog[0].action, "invoke");
assert.equal(bodyLog[0].args.prompt, "Summarize");
assert.equal(bodyLog[0].args.model, "claude-3-sonnet");
assert.equal(bodyLog[0].tool, 'llm-task');
assert.equal(bodyLog[0].action, 'invoke');
assert.equal(bodyLog[0].args.prompt, 'Summarize');
assert.equal(bodyLog[0].args.model, 'claude-3-sonnet');
assert.equal(bodyLog[0].args.artifacts.length, 1);
assert.equal(bodyLog[0].args.artifactHashes.length, 1);
} finally {
@ -103,15 +100,15 @@ test("llm_task.invoke posts to /tools/invoke (clawd) and normalizes result", asy
}
});
test("llm_task.invoke retries when schema validation fails", async () => {
test('llm_task.invoke retries when schema validation fails', async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("llm_task.invoke");
const cmd = registry.get('llm_task.invoke');
assert.ok(cmd);
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
let calls = 0;
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
res.writeHead(404);
res.end();
return;
@ -124,38 +121,35 @@ test("llm_task.invoke retries when schema validation fails", async () => {
ok: true,
result: {
runId: `attempt_${calls}`,
output: valid ? { data: { decision: "send" } } : { data: { foo: "bar" } },
output: valid ? { data: { decision: 'send' } } : { data: { foo: 'bar' } },
},
},
};
res.writeHead(200, { "content-type": "application/json" });
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(payload));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const port = typeof addr === 'object' && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([]),
args: {
_: [],
model: "claude-3-opus",
prompt: "Decide",
"output-schema": '{"type":"object","required":["decision"]}',
"max-validation-retries": 2,
model: 'claude-3-opus',
prompt: 'Decide',
'output-schema': '{"type":"object","required":["decision"]}',
'max-validation-retries': 2,
},
ctx: baseCtx(
{ LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` },
registry,
),
ctx: baseCtx({ LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
assert.equal(items[0].runId, "attempt_2");
assert.equal(items[0].output.data.decision, "send");
assert.equal(items[0].runId, 'attempt_2');
assert.equal(items[0].output.data.decision, 'send');
assert.equal(calls, 2);
} finally {
await rm(cacheDir, { recursive: true, force: true });
@ -163,68 +157,65 @@ test("llm_task.invoke retries when schema validation fails", async () => {
}
});
test("llm_task.invoke persists to run state so resume skips remote call", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "lobster-state-"));
test('llm_task.invoke persists to run state so resume skips remote call', async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), 'lobster-state-'));
const registry = createDefaultRegistry();
const cmd = registry.get("llm_task.invoke");
const cmd = registry.get('llm_task.invoke');
assert.ok(cmd);
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
res.writeHead(404);
res.end("not found");
res.end('not found');
return;
}
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
void buf;
res.writeHead(200, { "content-type": "application/json" });
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
ok: true,
result: { ok: true, result: { runId: "state_run", output: { data: { ok: true } } } },
}),
JSON.stringify({ ok: true, result: { ok: true, result: { runId: 'state_run', output: { data: { ok: true } } } } }),
);
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const port = typeof addr === 'object' && addr ? addr.port : 0;
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const ctxEnv = { LOBSTER_STATE_DIR: stateDir, LOBSTER_CACHE_DIR: cacheDir };
try {
const first = await cmd.run({
input: streamOf([{ foo: "bar" }]),
input: streamOf([{ foo: 'bar' }]),
args: {
_: [],
model: "claude",
prompt: "Do thing",
"state-key": "run123",
model: 'claude',
prompt: 'Do thing',
'state-key': 'run123',
},
ctx: baseCtx({ ...ctxEnv, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const firstItems = await collect(first.output!);
assert.equal(firstItems[0].source, "clawd");
assert.equal(firstItems[0].source, 'clawd');
await closeServer(server);
const second = await cmd.run({
input: streamOf([{ foo: "bar" }]),
input: streamOf([{ foo: 'bar' }]),
args: {
_: [],
model: "claude",
prompt: "Do thing",
"state-key": "run123",
model: 'claude',
prompt: 'Do thing',
'state-key': 'run123',
},
ctx: baseCtx({ ...ctxEnv, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const secondItems = await collect(second.output!);
assert.equal(secondItems.length, 1);
assert.equal(secondItems[0].source, "run_state");
assert.equal(secondItems[0].source, 'run_state');
} finally {
await rm(stateDir, { recursive: true, force: true });
await rm(cacheDir, { recursive: true, force: true });
@ -232,35 +223,32 @@ test("llm_task.invoke persists to run state so resume skips remote call", async
}
});
test("llm_task.invoke reuses file cache when URL unavailable", async () => {
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
test('llm_task.invoke reuses file cache when URL unavailable', async () => {
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const registry = createDefaultRegistry();
const cmd = registry.get("llm_task.invoke");
const cmd = registry.get('llm_task.invoke');
assert.ok(cmd);
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
res.writeHead(404);
res.end("not found");
res.end('not found');
return;
}
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
void buf;
res.writeHead(200, { "content-type": "application/json" });
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
ok: true,
result: { ok: true, result: { runId: "cache_run", output: { text: "cached" } } },
}),
JSON.stringify({ ok: true, result: { ok: true, result: { runId: 'cache_run', output: { text: 'cached' } } } }),
);
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const port = typeof addr === 'object' && addr ? addr.port : 0;
const ctxEnv = { LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` };
@ -269,13 +257,13 @@ test("llm_task.invoke reuses file cache when URL unavailable", async () => {
input: streamOf([]),
args: {
_: [],
model: "claude",
prompt: "Cache me",
model: 'claude',
prompt: 'Cache me',
},
ctx: baseCtx({ ...ctxEnv, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const firstItems = await collect(first.output!);
assert.equal(firstItems[0].source, "clawd");
assert.equal(firstItems[0].source, 'clawd');
await closeServer(server);
@ -283,14 +271,14 @@ test("llm_task.invoke reuses file cache when URL unavailable", async () => {
input: streamOf([]),
args: {
_: [],
model: "claude",
prompt: "Cache me",
model: 'claude',
prompt: 'Cache me',
},
ctx: baseCtx({ ...ctxEnv, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const secondItems = await collect(second.output!);
assert.equal(secondItems.length, 1);
assert.equal(secondItems[0].source, "cache");
assert.equal(secondItems[0].source, 'cache');
assert.equal(secondItems[0].cached, true);
} finally {
await rm(cacheDir, { recursive: true, force: true });
@ -298,38 +286,38 @@ test("llm_task.invoke reuses file cache when URL unavailable", async () => {
}
});
test("llm_task.invoke uses CLAWD_URL (/tools/invoke) without requiring --url/--model", async () => {
test('llm_task.invoke uses CLAWD_URL (/tools/invoke) without requiring --url/--model', async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("llm_task.invoke");
const cmd = registry.get('llm_task.invoke');
assert.ok(cmd);
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const bodyLog: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
res.writeHead(404);
res.end("not found");
res.end('not found');
return;
}
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
const parsed = JSON.parse(buf || "{}");
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
const parsed = JSON.parse(buf || '{}');
bodyLog.push(parsed);
// This is the OpenClaw tool router envelope.
res.writeHead(200, { "content-type": "application/json" });
// This is the Clawdbot tool router envelope.
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
ok: true,
result: {
ok: true,
result: {
runId: "task_clawd_1",
output: { data: { hello: "world" } },
runId: 'task_clawd_1',
output: { data: { hello: 'world' } },
},
},
}),
@ -339,34 +327,31 @@ test("llm_task.invoke uses CLAWD_URL (/tools/invoke) without requiring --url/--m
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const port = typeof addr === 'object' && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([{ kind: "text", text: "doc" }]),
input: streamOf([{ kind: 'text', text: 'doc' }]),
args: {
_: [],
// no url, no model
prompt: "Summarize",
prompt: 'Summarize',
refresh: true,
},
ctx: baseCtx(
{ CLAWD_URL: `http://localhost:${port}`, LOBSTER_CACHE_DIR: cacheDir },
registry,
),
ctx: baseCtx({ CLAWD_URL: `http://localhost:${port}`, LOBSTER_CACHE_DIR: cacheDir }, registry),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
assert.equal(items[0].source, "clawd");
assert.equal(items[0].source, 'clawd');
assert.equal(items[0].cached, false);
assert.equal(items[0].runId, "task_clawd_1");
assert.equal(items[0].output.data.hello, "world");
assert.equal(items[0].runId, 'task_clawd_1');
assert.equal(items[0].output.data.hello, 'world');
assert.equal(bodyLog.length, 1);
assert.equal(bodyLog[0].tool, "llm-task");
assert.equal(bodyLog[0].action, "invoke");
assert.equal(bodyLog[0].args.prompt, "Summarize");
assert.equal(bodyLog[0].tool, 'llm-task');
assert.equal(bodyLog[0].action, 'invoke');
assert.equal(bodyLog[0].args.prompt, 'Summarize');
assert.ok(Array.isArray(bodyLog[0].args.artifactHashes));
} finally {
await rm(cacheDir, { recursive: true, force: true });
@ -381,7 +366,7 @@ function baseCtx(envOverrides: Record<string, string>, registry?) {
stderr: process.stderr,
env: { ...process.env, ...envOverrides },
registry: registry ?? null,
mode: "tool",
mode: 'tool',
render: { json() {}, lines() {} },
};
}

View File

@ -1,9 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import test from 'node:test';
import assert from 'node:assert/strict';
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { parsePipeline } from "../src/parser.js";
import { runPipeline } from '../src/runtime.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { parsePipeline } from '../src/parser.js';
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
@ -15,31 +15,29 @@ async function run(pipelineText: string, input: any[]) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
mode: 'tool',
input: (async function* () { for (const x of input) yield x; })(),
});
return res.items;
}
test("map --wrap wraps items", async () => {
const out = await run("map --wrap item", [1, 2]);
test('map --wrap wraps items', async () => {
const out = await run('map --wrap item', [1, 2]);
assert.deepEqual(out, [{ item: 1 }, { item: 2 }]);
});
test("map --unwrap unwraps fields", async () => {
const out = await run("map --unwrap x", [{ x: 1 }, { x: 2 }]);
test('map --unwrap unwraps fields', async () => {
const out = await run('map --unwrap x', [{ x: 1 }, { x: 2 }]);
assert.deepEqual(out, [1, 2]);
});
test("map adds fields via assignments with template values", async () => {
const out = await run("map kind=pr id={{id}}", [{ id: 123, title: "t" }]);
test('map adds fields via assignments with template values', async () => {
const out = await run('map kind=pr id={{id}}', [{ id: 123, title: 't' }]);
// assignment overwrites existing id with rendered string
assert.deepEqual(out, [{ id: "123", title: "t", kind: "pr" }]);
assert.deepEqual(out, [{ id: '123', title: 't', kind: 'pr' }]);
});
test("map converts non-object items to {value: item} when adding fields", async () => {
const out = await run("map kind=num", [5]);
assert.deepEqual(out, [{ value: 5, kind: "num" }]);
test('map converts non-object items to {value: item} when adding fields', async () => {
const out = await run('map kind=num', [5]);
assert.deepEqual(out, [{ value: 5, kind: 'num' }]);
});

View File

@ -1,59 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import os from "node:os";
import { promises as fsp } from "node:fs";
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
function runTool(pipeline, env) {
const bin = path.join(process.cwd(), "bin", "lobster.js");
const res = spawnSync("node", [bin, "run", "--mode", "tool", pipeline], {
encoding: "utf8",
env: { ...process.env, ...env },
function runTool(pipeline) {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
const res = spawnSync('node', [bin, 'run', '--mode', 'tool', pipeline], { encoding: 'utf8' });
assert.equal(res.status, 0);
return JSON.parse(res.stdout);
}
function resume(token, approve) {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
const res = spawnSync('node', [bin, 'resume', '--token', token, '--approve', approve ? 'yes' : 'no'], {
encoding: 'utf8',
});
assert.equal(res.status, 0);
return JSON.parse(res.stdout);
}
function resume(token, approve, env) {
const bin = path.join(process.cwd(), "bin", "lobster.js");
const res = spawnSync(
"node",
[bin, "resume", "--token", token, "--approve", approve ? "yes" : "no"],
{
encoding: "utf8",
env: { ...process.env, ...env },
},
);
assert.equal(res.status, 0);
return JSON.parse(res.stdout);
}
test("two approve gates can be resumed sequentially", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-multi-approval-"));
const stateDir = path.join(tmpDir, "state");
const env = { LOBSTER_STATE_DIR: stateDir };
test('two approve gates can be resumed sequentially', () => {
const pipeline = [
"exec --json --shell \"printf '%s' '[{\\\"x\\\":1}]'\"",
"approve --prompt 'first?'",
"approve --prompt 'second?'",
"pick x",
].join(" | ");
'pick x',
].join(' | ');
const first = runTool(pipeline, env);
assert.equal(first.status, "needs_approval");
assert.equal(first.requiresApproval.prompt, "first?");
const first = runTool(pipeline);
assert.equal(first.status, 'needs_approval');
assert.equal(first.requiresApproval.prompt, 'first?');
const second = resume(first.requiresApproval.resumeToken, true, env);
assert.equal(second.status, "needs_approval");
assert.equal(second.requiresApproval.prompt, "second?");
const second = resume(first.requiresApproval.resumeToken, true);
assert.equal(second.status, 'needs_approval');
assert.equal(second.requiresApproval.prompt, 'second?');
const done = resume(second.requiresApproval.resumeToken, true, env);
assert.equal(done.status, "ok");
const done = resume(second.requiresApproval.resumeToken, true);
assert.equal(done.status, 'ok');
assert.deepEqual(done.output, [{ x: 1 }]);
const files = await fsp.readdir(stateDir);
const pipelineResumeFiles = files.filter((name) => name.startsWith("pipeline_resume_"));
assert.deepEqual(pipelineResumeFiles, []);
});

View File

@ -1,194 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { loadWorkflowFile, runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-onerror-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
registry: createDefaultRegistry(),
},
});
}
test("on_error defaults to stop and propagates errors", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "fail", command: 'node -e "process.exit(1)"' },
{ id: "after", command: "echo should-not-run" },
],
}),
/workflow command failed/,
);
});
test("on_error: stop explicit also propagates errors", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "fail", command: 'node -e "process.exit(1)"', on_error: "stop" },
{ id: "after", command: "echo should-not-run" },
],
}),
/workflow command failed/,
);
});
test("on_error: continue records error and proceeds", async () => {
const result = await runWorkflow({
steps: [
{ id: "fail", command: 'node -e "process.exit(1)"', on_error: "continue" },
{ id: "after", command: 'echo "ran"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["ran\n"]);
});
test("on_error: continue exposes error marker to later steps", async () => {
const result = await runWorkflow({
steps: [
{ id: "fail", command: 'node -e "process.exit(1)"', on_error: "continue" },
{
id: "check",
command: 'node -e "process.stdout.write(JSON.stringify({saw: process.env.SAW}))"',
env: { SAW: "$fail.error" },
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ saw: "true" }]);
});
test("on_error: skip_rest stops remaining steps and keeps prior output", async () => {
const result = await runWorkflow({
steps: [
{ id: "good", command: 'node -e "process.stdout.write(JSON.stringify({kept:true}))"' },
{ id: "fail", command: 'node -e "process.exit(1)"', on_error: "skip_rest" },
{ id: "after", command: "echo should-not-run" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ kept: true }]);
});
test("on_error: continue supports condition branching on error state", async () => {
const result = await runWorkflow({
steps: [
{ id: "risky", command: 'node -e "process.exit(1)"', on_error: "continue" },
{ id: "success_path", command: "echo success", when: "$risky.error != true" },
{ id: "failure_path", command: "echo failure", when: "$risky.error == true" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["failure\n"]);
});
test("on_error validation rejects invalid values", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-onerror-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [{ id: "x", command: "echo hi", on_error: "invalid" }],
}),
"utf8",
);
await assert.rejects(
() => loadWorkflowFile(filePath),
/on_error must be "stop", "continue", or "skip_rest"/,
);
});
test("multiple continue failures preserve both error markers", async () => {
const result = await runWorkflow({
steps: [
{ id: "a", command: 'node -e "process.exit(1)"', on_error: "continue" },
{ id: "b", command: 'node -e "process.exit(1)"', on_error: "continue" },
{
id: "report",
command:
'node -e "process.stdout.write(JSON.stringify({a:process.env.A,b:process.env.B}))"',
env: { A: "$a.error", B: "$b.error" },
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ a: "true", b: "true" }]);
});
test("on_error: continue preserves output when final step fails", async () => {
const result = await runWorkflow({
steps: [
{ id: "good", command: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"' },
{ id: "fail_last", command: 'node -e "process.exit(1)"', on_error: "continue" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ ok: true }]);
});
test("pipeline approval halts bypass on_error handling", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "gate", pipeline: "approve --prompt 'Proceed?'", on_error: "continue" },
{ id: "after", command: "echo should-not-run" },
],
}),
/halted for approval inside pipeline/,
);
});
test("external abort propagates even with on_error: continue", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-onerror-abort-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{ id: "slow", command: 'node -e "setTimeout(() => {}, 5000)"', on_error: "continue" },
],
}),
"utf8",
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
signal: controller.signal,
},
}),
(err: any) => err?.name === "AbortError" || err?.code === "ABORT_ERR",
);
});

View File

@ -1,13 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createDefaultRegistry } from "../src/commands/registry.js";
test("clawd.invoke remains available as an alias", async () => {
const registry = createDefaultRegistry();
const a = registry.get("openclaw.invoke");
const b = registry.get("clawd.invoke");
assert.ok(a, "expected openclaw.invoke to exist");
assert.ok(b, "expected clawd.invoke to exist");
assert.equal(typeof a.run, "function");
assert.equal(typeof b.run, "function");
});

View File

@ -1,203 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { loadWorkflowFile, runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
registry: createDefaultRegistry(),
},
});
}
test("parallel wait=all runs all branches and merges output", async () => {
const result = await runWorkflow({
steps: [
{
id: "fetch",
parallel: {
wait: "all",
branches: [
{ id: "a", command: 'node -e "process.stdout.write(JSON.stringify({src:\\"a\\"}))"' },
{ id: "b", command: 'node -e "process.stdout.write(JSON.stringify({src:\\"b\\"}))"' },
{ id: "c", command: 'node -e "process.stdout.write(JSON.stringify({src:\\"c\\"}))"' },
],
},
},
],
});
assert.equal(result.status, "ok");
const output = result.output as any[];
assert.equal(output[0].a.src, "a");
assert.equal(output[0].b.src, "b");
assert.equal(output[0].c.src, "c");
});
test("parallel wait=any returns first branch result", async () => {
const result = await runWorkflow({
steps: [
{
id: "race",
parallel: {
wait: "any",
branches: [
{
id: "fast",
command: 'node -e "process.stdout.write(JSON.stringify({winner:true}))"',
},
{
id: "slow",
command:
'node -e "setTimeout(() => process.stdout.write(JSON.stringify({winner:false})), 5000)"',
},
],
},
},
],
});
assert.equal(result.status, "ok");
const output = result.output as any[];
assert.equal(Object.keys(output[0]).length, 1);
assert.equal(output[0].fast.winner, true);
});
test("parallel branch results are available to later steps by branch id", async () => {
const result = await runWorkflow({
steps: [
{
id: "fetch",
parallel: {
wait: "all",
branches: [
{ id: "x", command: 'node -e "process.stdout.write(JSON.stringify({val:10}))"' },
{ id: "y", command: 'node -e "process.stdout.write(JSON.stringify({val:20}))"' },
],
},
},
{
id: "use",
command: 'node -e "process.stdout.write(JSON.stringify({x:$x.json.val,y:$y.json.val}))"',
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ x: 10, y: 20 }]);
});
test("parallel wait=all propagates branch failure", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{
id: "p",
parallel: {
wait: "all",
branches: [
{ id: "ok", command: "echo ok" },
{ id: "fail", command: 'node -e "process.exit(1)"' },
],
},
},
],
}),
/Parallel branch failed/,
);
});
test("parallel validation rejects empty branches", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [{ id: "p", parallel: { branches: [] } }],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /non-empty branches/);
});
test("parallel validation rejects duplicate branch ids", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "p",
parallel: {
branches: [
{ id: "dup", command: "echo a" },
{ id: "dup", command: "echo b" },
],
},
},
],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /duplicate parallel branch id/);
});
test("parallel validation rejects branch without execution", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "p",
parallel: {
branches: [{ id: "empty" }],
},
},
],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /requires run, command, or pipeline/);
});
test("parallel timeout aborts block", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{
id: "p",
parallel: {
wait: "all",
timeout_ms: 100,
branches: [
{
id: "slow",
command: "node -e \"setTimeout(() => process.stdout.write('ok'), 5000)\"",
},
],
},
},
],
}),
/Parallel step p timed out after 100ms/,
);
});

View File

@ -1,45 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parsePipeline } from "../src/parser.js";
import test from 'node:test';
import assert from 'node:assert/strict';
import { parsePipeline } from '../src/parser.js';
test("parsePipeline splits stages and args", () => {
test('parsePipeline splits stages and args', () => {
const p = parsePipeline("exec echo hi | where a=1 | pick id,subject");
assert.equal(p.length, 3);
assert.equal(p[0].name, "exec");
assert.deepEqual(p[0].args._, ["echo", "hi"]);
assert.equal(p[1].name, "where");
assert.equal(p[1].args._[0], "a=1");
assert.equal(p[2].name, "pick");
assert.equal(p[2].args._[0], "id,subject");
assert.equal(p[0].name, 'exec');
assert.deepEqual(p[0].args._, ['echo', 'hi']);
assert.equal(p[1].name, 'where');
assert.equal(p[1].args._[0], 'a=1');
assert.equal(p[2].name, 'pick');
assert.equal(p[2].args._[0], 'id,subject');
});
test("parsePipeline keeps quoted pipes", () => {
test('parsePipeline keeps quoted pipes', () => {
const p = parsePipeline("exec echo 'a|b' | json");
assert.equal(p.length, 2);
assert.deepEqual(p[0].args._, ["echo", "a|b"]);
});
test("parsePipeline preserves JSON escapes in double-quoted args", () => {
const p = parsePipeline(
'openclaw.invoke --tool llm-task --action json --args-json "{\\"prompt\\":\\"line1\\\\nline2\\",\\"schema\\":{\\"type\\":\\"object\\"}}"',
);
assert.equal(p.length, 1);
const raw = p[0].args["args-json"];
const parsed = JSON.parse(raw);
assert.equal(parsed.prompt, "line1\nline2");
assert.equal(parsed.schema.type, "object");
});
test("parsePipeline keeps single-quoted args literal", () => {
const p = parsePipeline('openclaw.invoke --args-json \'{"x":"a\\\\nb"}\'');
assert.equal(p.length, 1);
assert.equal(p[0].args["args-json"], '{"x":"a\\\\nb"}');
});
test("parsePipeline preserves escaped apostrophes in single-quoted args", () => {
const p = parsePipeline('openclaw.invoke --args-json \'{"prompt":"don\\\'t"}\'');
assert.equal(p.length, 1);
const raw = p[0].args["args-json"];
const parsed = JSON.parse(raw);
assert.equal(parsed.prompt, "don't");
assert.deepEqual(p[0].args._, ['echo', 'a|b']);
});

View File

@ -1,33 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { PassThrough } from "node:stream";
import { readLineFromStream } from "../src/read_line.js";
test("readLineFromStream resolves on newline", async () => {
const input = new PassThrough();
const promise = readLineFromStream(input);
input.write("yes\n");
input.end();
const value = await promise;
assert.equal(value, "yes");
});
test("readLineFromStream resolves on end without newline", async () => {
const input = new PassThrough();
const promise = readLineFromStream(input);
input.write("partial");
input.end();
const value = await promise;
assert.equal(value, "partial");
});
test("readLineFromStream times out when no input arrives", async () => {
const input = new PassThrough();
await assert.rejects(
() => readLineFromStream(input, { timeoutMs: 5 }),
/Timed out waiting for input/,
);
});

Some files were not shown because too many files have changed in this diff Show More