mirror of
https://github.com/openclaw/lobster.git
synced 2026-08-14 00:48:09 +00:00
* fix(runtime): stop process-backed work on cancellation
* fix(runtime): invalidate cancelled resume state
* Revert "fix(runtime): invalidate cancelled resume state"
This reverts commit 0f7d291f98.
* fix(runtime): narrow cancellation to safe child processes
* fix(runtime): stop after completed search cancellation
* fix(runtime): halt direct pipelines after cancellation
Preserve completed in-flight stage results while preventing later direct pipeline stages from starting after parent cancellation.
* fix(runtime): consume aborted approval resumes
* fix(runtime): cancelled workflows no longer continue external commands
* fix(workflow): propagate custom parent cancellation
* fix(runtime): preserve pre-aborted resume state
* fix(runtime): stop lazy handoff after cancellation
* fix(workflow): close remaining cancellation boundaries
* fix(runtime): preserve workflow resumes during setup cancellation
* fix(runtime): close final cancellation persistence gaps
* fix(runtime): stop lazy handoff after cancellation
* fix(runtime): interrupt blocked lazy handoff reads
* fix(runtime): terminate cancellation process trees
* fix(runtime): await process tree termination
* fix(runtime): terminate workflow process trees
* fix(runtime): bridge CLI cancellation
* fix(cli): preserve cancellation lifecycle
* fix(cli): abort stalled signal-aware commands
* fix(cli): release aborted interactive prompts
* fix(cli): preserve sequential prompt input
* fix(cli): preserve buffered prompt input
* fix(cli): handle prompt EOF after buffered input
* fix(runtime): preserve UTF-8 subprocess output
* fix(workflow): preserve retryable resume before execution
* fix(workflow): roll back cancelled resume replacement
* fix(state): roll back cancelled monitor snapshot
* fix(resume): preserve cancelled gate capabilities
* fix(resume): close cancellation rollback windows
* fix(resume): harden cancellation state cleanup
* fix(runtime): close resumed cancellation gaps
* fix(runtime): preserve cancellation cleanup
* fix(runtime): close cancellation lifecycle gaps
* fix(runtime): harden resumed cancellation boundaries
* fix(workflow): consume timed-out resume capabilities
* fix(workflow): preserve resume policy boundaries
* fix(runtime): preserve cancellation cleanup liveness
* fix(runtime): harden cancellation cleanup
* fix(runtime): stop lazy output after cancellation
* fix(runtime): settle cancellation cleanup
* fix(runtime): preserve safe input resumes
* fix(runtime): prevent consumed resume replays
* fix(runtime): serialize approval resume consumption
* fix(runtime): prevent concurrent safe gate forks
* fix(runtime): close cancellation review gaps
* fix(llm): restore cache after cancelled refresh
* fix(runtime): close remaining cancellation windows
* fix(runtime): preserve cancellation recovery invariants
* fix(runtime): prevent stale resume recovery
* fix(runtime): preserve resume claim recovery
* fix(runtime): retry pre-dispatch claims safely
* fix(state): synchronize rollback-safe reads
* fix(resume): discard cancelled pipeline successors
* fix(runtime): harden cancellation and state locking
* fix(runtime): recover durable cancellation failures
* fix(runtime): preserve legacy workflow cancellation
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
748 lines
18 KiB
TypeScript
748 lines
18 KiB
TypeScript
import { parsePipeline } from "./parser.js";
|
|
import { createDefaultRegistry } from "./commands/registry.js";
|
|
import { runPipeline } from "./runtime.js";
|
|
import { parseResumeArgs } from "./resume.js";
|
|
import { resumeToolRequest } from "./core/tool_runtime.js";
|
|
import { loadWorkflowFile, resolveWorkflowArgs, runWorkflowFile } from "./workflows/file.js";
|
|
import { renderWorkflowGraph } from "./workflows/graph.js";
|
|
import type { WorkflowGraphFormat } from "./workflows/graph.js";
|
|
import { finalizePipelineToolRun } from "./pipeline_resume_state.js";
|
|
import { forceTerminateAbortableProcesses } from "./abortable_process.js";
|
|
|
|
export async function runCli(argv) {
|
|
const cancellation = createCliCancellation();
|
|
try {
|
|
await runCliWithSignal(argv, cancellation.signal, cancellation.signal);
|
|
} finally {
|
|
if (cancellation.exitCode !== undefined) {
|
|
process.exitCode = cancellation.exitCode;
|
|
}
|
|
cancellation.dispose();
|
|
}
|
|
}
|
|
|
|
async function runCliWithSignal(
|
|
argv,
|
|
signal: AbortSignal,
|
|
forceTerminationSignal: AbortSignal = signal,
|
|
) {
|
|
const registry = createDefaultRegistry();
|
|
|
|
if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
|
|
process.stdout.write(helpText());
|
|
return;
|
|
}
|
|
|
|
if (argv[0] === "help") {
|
|
const topic = argv[1];
|
|
if (!topic) {
|
|
process.stdout.write(helpText());
|
|
return;
|
|
}
|
|
const cmd = registry.get(topic);
|
|
if (!cmd) {
|
|
process.stderr.write(`Unknown command: ${topic}\n`);
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
process.stdout.write(cmd.help());
|
|
return;
|
|
}
|
|
|
|
if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
|
|
process.stdout.write(`${await readVersion()}\n`);
|
|
return;
|
|
}
|
|
|
|
if (argv[0] === "doctor") {
|
|
await handleDoctor({ argv: argv.slice(1), registry, signal, forceTerminationSignal });
|
|
return;
|
|
}
|
|
|
|
if (argv[0] === "graph") {
|
|
await handleGraph({ argv: argv.slice(1) });
|
|
return;
|
|
}
|
|
|
|
if (argv[0] === "run") {
|
|
await handleRun({ argv: argv.slice(1), registry, signal, forceTerminationSignal });
|
|
return;
|
|
}
|
|
|
|
if (argv[0] === "resume") {
|
|
await handleResume({ argv: argv.slice(1), registry, signal, forceTerminationSignal });
|
|
return;
|
|
}
|
|
|
|
// Default: treat argv as a pipeline string.
|
|
await handleRun({ argv, registry, signal, forceTerminationSignal });
|
|
}
|
|
|
|
function createCliCancellation() {
|
|
const controller = new AbortController();
|
|
let received: NodeJS.Signals | undefined;
|
|
const terminatingSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
|
|
const abort = (receivedSignal: NodeJS.Signals) => {
|
|
if (received) {
|
|
forceTerminateAbortableProcesses(controller.signal);
|
|
return;
|
|
}
|
|
received = receivedSignal;
|
|
controller.abort(new Error(`Received ${receivedSignal}`));
|
|
};
|
|
const listeners = new Map<NodeJS.Signals, () => void>();
|
|
for (const terminatingSignal of terminatingSignals) {
|
|
const listener = () => abort(terminatingSignal);
|
|
listeners.set(terminatingSignal, listener);
|
|
process.on(terminatingSignal, listener);
|
|
}
|
|
|
|
return {
|
|
signal: controller.signal,
|
|
get exitCode() {
|
|
if (!received) return undefined;
|
|
return (
|
|
{
|
|
SIGINT: 130,
|
|
SIGHUP: 129,
|
|
SIGQUIT: 131,
|
|
SIGTERM: 143,
|
|
} as const
|
|
)[received];
|
|
},
|
|
dispose() {
|
|
for (const [terminatingSignal, listener] of listeners) {
|
|
process.removeListener(terminatingSignal, listener);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
signal,
|
|
forceTerminationSignal,
|
|
}: {
|
|
argv;
|
|
registry;
|
|
signal: AbortSignal;
|
|
forceTerminationSignal: AbortSignal;
|
|
}) {
|
|
const parsed = parseRunArgs(argv);
|
|
const { mode, argsJson } = parsed;
|
|
const normalizedMode = normalizeMode(mode);
|
|
const { rest, filePath, dryRun } = await resolveRunTarget(parsed);
|
|
|
|
const workflowFile = filePath
|
|
? await resolveWorkflowFile(filePath)
|
|
: await detectWorkflowFile(rest);
|
|
if (workflowFile) {
|
|
let parsedArgs = {};
|
|
if (argsJson) {
|
|
try {
|
|
parsedArgs = JSON.parse(argsJson);
|
|
} catch {
|
|
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.exitCode = 2;
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const output = await runWorkflowFile({
|
|
filePath: workflowFile,
|
|
args: parsedArgs,
|
|
ctx: {
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
env: process.env,
|
|
mode: normalizedMode,
|
|
registry,
|
|
dryRun,
|
|
signal,
|
|
forceTerminationSignal,
|
|
},
|
|
});
|
|
|
|
if (normalizedMode === "tool") {
|
|
if (output.status === "needs_approval") {
|
|
writeToolEnvelope({
|
|
ok: true,
|
|
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",
|
|
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) {
|
|
process.stdout.write(JSON.stringify(output.output, null, 2));
|
|
process.stdout.write("\n");
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
if (normalizedMode === "tool") {
|
|
writeToolEnvelope({
|
|
ok: false,
|
|
error: { type: "runtime_error", message: err?.message ?? String(err) },
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
|
|
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) },
|
|
});
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
process.stderr.write(`Parse error: ${err?.message ?? String(err)}\n`);
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const output = await runPipeline({
|
|
pipeline,
|
|
registry,
|
|
input: [],
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
env: process.env,
|
|
mode: normalizedMode,
|
|
dryRun,
|
|
signal,
|
|
forceTerminationSignal,
|
|
haltAfterStageOnAbort: true,
|
|
});
|
|
|
|
if (normalizedMode === "tool") {
|
|
const finalized = await finalizePipelineToolRun({
|
|
env: process.env,
|
|
pipeline,
|
|
output,
|
|
signal,
|
|
});
|
|
writeToolEnvelope({
|
|
ok: true,
|
|
status: finalized.status,
|
|
output: finalized.output,
|
|
requiresApproval: finalized.requiresApproval,
|
|
requiresInput: finalized.requiresInput,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (output.halted && isPipelineInputRequest(output.items)) {
|
|
throw new Error("requestInput requires --mode tool when stdin is not interactive");
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
} catch (err) {
|
|
if (normalizedMode === "tool") {
|
|
writeToolEnvelope({
|
|
ok: false,
|
|
error: { type: "runtime_error", message: err?.message ?? String(err) },
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
function isPipelineInputRequest(items) {
|
|
return (
|
|
items.length === 1 && items[0]?.type === "input_request" && items[0]?.commandInput !== undefined
|
|
);
|
|
}
|
|
|
|
function parseRunArgs(argv) {
|
|
const rest = [];
|
|
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") {
|
|
const value = argv[i + 1];
|
|
if (value) {
|
|
mode = value;
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (tok.startsWith("--mode=")) {
|
|
mode = tok.slice("--mode=".length) || "human";
|
|
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 === "--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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function normalizeMode(mode) {
|
|
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;
|
|
try {
|
|
return await resolveWorkflowFile(candidate);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveWorkflowFile(candidate) {
|
|
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");
|
|
|
|
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");
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
async function handleResume({
|
|
argv,
|
|
registry,
|
|
signal,
|
|
forceTerminationSignal,
|
|
}: {
|
|
argv;
|
|
registry;
|
|
signal: AbortSignal;
|
|
forceTerminationSignal: AbortSignal;
|
|
}) {
|
|
let parsed;
|
|
try {
|
|
parsed = parseResumeArgs(argv);
|
|
} catch (err) {
|
|
writeToolEnvelope({
|
|
ok: false,
|
|
error: { type: "parse_error", message: err?.message ?? String(err) },
|
|
});
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
|
|
let envelope;
|
|
try {
|
|
envelope = await resumeToolRequest({
|
|
token: parsed.token ?? undefined,
|
|
approvalId: parsed.approvalId ?? undefined,
|
|
approved: parsed.approved,
|
|
response: parsed.response,
|
|
cancel: parsed.cancel,
|
|
ctx: {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
mode: "tool",
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
registry,
|
|
signal,
|
|
forceTerminationSignal,
|
|
},
|
|
});
|
|
} catch (err: any) {
|
|
envelope = {
|
|
ok: false,
|
|
error: { type: "runtime_error", message: err?.message ?? String(err) },
|
|
};
|
|
}
|
|
writeToolEnvelope(envelope);
|
|
if (!envelope.ok) {
|
|
process.exitCode = envelope.error?.type === "parse_error" ? 2 : 1;
|
|
}
|
|
}
|
|
|
|
async function readVersion() {
|
|
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";
|
|
}
|
|
|
|
async function handleDoctor({
|
|
argv,
|
|
registry,
|
|
signal,
|
|
forceTerminationSignal,
|
|
}: {
|
|
argv;
|
|
registry;
|
|
signal: AbortSignal;
|
|
forceTerminationSignal: AbortSignal;
|
|
}) {
|
|
const mode = "tool";
|
|
const pipeline = "exec --json --shell 'echo [1]'";
|
|
const output: any = await (async () => {
|
|
try {
|
|
const parsed = parsePipeline(pipeline);
|
|
return await runPipeline({
|
|
pipeline: parsed,
|
|
registry,
|
|
input: [],
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
env: process.env,
|
|
mode,
|
|
signal,
|
|
forceTerminationSignal,
|
|
haltAfterStageOnAbort: true,
|
|
});
|
|
} catch (err: any) {
|
|
return { error: err };
|
|
}
|
|
})();
|
|
|
|
if (output?.error) {
|
|
writeToolEnvelope({
|
|
ok: false,
|
|
error: { type: "doctor_error", message: output.error?.message ?? String(output.error) },
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
writeToolEnvelope({
|
|
ok: true,
|
|
status: "ok",
|
|
output: [
|
|
{
|
|
toolMode: true,
|
|
protocolVersion: 1,
|
|
version: await readVersion(),
|
|
notes: argv.length ? argv : undefined,
|
|
},
|
|
],
|
|
requiresApproval: null,
|
|
requiresInput: null,
|
|
});
|
|
}
|
|
|
|
function writeToolEnvelope(payload) {
|
|
const envelope = {
|
|
protocolVersion: 1,
|
|
...payload,
|
|
};
|
|
process.stdout.write(JSON.stringify(envelope, null, 2));
|
|
process.stdout.write("\n");
|
|
}
|
|
|
|
function helpText() {
|
|
return (
|
|
`lobster — OpenClaw-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` +
|
|
`Examples:\n` +
|
|
` 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.agent, 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`
|
|
);
|
|
}
|