LFAgent
Global:LFAgent · Enable:// @require agent · Precondition:workspace: ['agent']
The entry file must declare // @require agent: without it the LFAgent global is never injected, so even a command that declares workspace: ['agent'] only gets ReferenceError: LFAgent is not defined inside its handler (the marketplace static review also only runs when agent is declared).
Agent orchestration: spawn native or custom AI agent instances that run in detached, read-only IDE sessions concurrently with the main Agent. These sessions are managed through the collapsible "Subagents" panel above the spawning session's composer (a tree that mirrors parent-child spawning; opening an entry shows the orange breathing tab) and never appear in the history list. Lease rule: temporary terminal scripts (run <file> / run -c / run --tab / run --target background) may call it directly at top level — the run itself is an IDE/agent authorization, so the IDE issues an agent lease for the process automatically (revoked when the process ends, force-stopping its agent runs), the same rule as direct readFile/writeFile/execTerminalCommand. Plugin source (liveDebug reinjections, enabled/published bundles) is only usable inside a LFRuntime.cli.register handler whose command declared workspace: ['agent'], and only while that plugincmd call is running — outside an invocation LFAgent.create / LFAgent.askUser throw WORKSPACE_LEASE_REQUIRED (in a race right as the call settles, the background may answer WORKSPACE_LEASE_EXPIRED instead); instance methods (send / status / stop / destroy / getTranscript) called after their creating call (or terminal run process) has ended throw LFAGENT_CALL_MISMATCH (or WORKSPACE_LEASE_REQUIRED if no lease is active at all); onProgress and the readonly properties (agentId / kind / sessionId) are not lease-checked. Published apps additionally need the LFRuntime.workspace permission; otherwise plugincmd call is rejected with exit code 126 (WORKSPACE_UNAUTHORIZED) and the handler never runs.
Methods
LFAgent.create(options): Promise<LFAgentInstance>Create an agent instance; kind is 'native' (reuses the full plugin agent with every built-in tool) or 'custom' (own system prompt + built-in whitelist). Both kinds may attach page-side custom tools via tools (add-only, see the table below). You must pass exactly one direct object literal ({} is fine): whenever agent is enabled, Compile / liveDebug / run <file> / run -c run the static review, so a bare LFAgent.create() or a variable argument fails to compile. The returned handle carries readonly agentId / kind / sessionId (the session is lazily created on the first send, so sessionId is an empty string before that; it is reset to "" by destroy and, for destroyOnEnd instances, after every send)
instance.send(text, options?): Promise<{ text, status, usage: { inputTokens, outputTokens, totalTokens, points, costUnit, pointsSource }, estimatedCostCny, sessionId }>Send an instruction and await the whole run; the first send lazily creates the session (no tab opens automatically — open it from the "Subagents" panel). The owning plugincmd call's ctx.signal cascades automatically, so you don't need to pass it; options.signal is an extra cancellation source (e.g. AbortSignal.timeout(ms)). Any abort tells the IDE to stop the run and rejects with LFAGENT_ABORTED, with the original reason in error.cause; if a signal is already aborted at call time, it rejects immediately with LFAGENT_ABORTED (cause is signal.reason) and no run starts. Sending again before the previous send finishes rejects with LFAGENT_BUSY. Blank text is rejected with LFAGENT_INVALID_OPTIONS. Result fields:
text: the model's final answer;"Agent 已结束。"when emptystatus: how the run ended (defaults to'completed')usage:inputTokens/outputTokens/totalTokens;pointsis the turn's points, ornullwhen no billing data is available;costUnitis always'points';pointsSourceis the billing source ('mixed'when actual and local-fallback turns are mixed; it may also be'unknown'/'unavailable'/'deprecated-estimated-cost-cny')estimatedCostCny:usage.points / 200, deprecated compatibility onlysessionId: the session used by this run (withdestroyOnEndit has already been destroyed when the promise resolves)
instance.stop(): Promise<{ stopped }>Abort the in-flight run (session preserved). It returns stopped: true only when a run was actually stopped; on an idle instance (no send yet, previous run already finished, or destroyed) it returns { stopped: false }
instance.destroy(): Promise<{ destroyed, sessionId }>Delete the bound session and close its tab; idempotent — succeeds when the session is already gone. The returned sessionId is the destroyed session's id ("" if no session was ever created); afterwards instance.sessionId is ""
instance.status(): Promise<{ agentId, kind, model, resolvedModel, reasoningEffort, resolvedReasoningEffort, destroyOnEnd, sessionId, sessionExists, running, messageCount, timelineCount }>Snapshot of the instance and its session: model / reasoningEffort are the requested values (reasoningEffort already normalized), resolvedModel / resolvedReasoningEffort are what they resolve to right now; sessionExists says whether the session record still exists; running says whether a run is in flight; messageCount / timelineCount are the session's API conversation record count and timeline entry count
instance.getTranscript(options?): Promise<{ sessionId, include, totalEntries, entries } | { sessionId, include, meta }>Export the session context; include is 'messages' (default, the API conversation records used in the prompt) / 'timeline' (session timeline entries) / 'meta'. messages / timeline return { sessionId, include, totalEntries, entries: [{ index, entry }] }; meta returns { sessionId, include, meta: { id, title, createdAt, updatedAt, origin, lfagentMeta } }. Strings longer than 4000 characters are truncated and marked …[truncated N chars]. Any other include value is rejected with LFAGENT_INVALID_OPTIONS; before the first send or after the session is destroyed it throws LFAGENT_SESSION_GONE
instance.onProgress(callback): () => voidSubscribe to coarse progress events; returns an unsubscribe function. The callback receives { event, ...payload }:
run-started:{ sessionId, model, reasoningEffort }(the model and effort actually sent)progress:{ sessionId, entryCount, lastEntryKind, lastEntryLabel }, pushed once per new session timeline entryrun-finished:{ sessionId }, pushed whether the run completed, failed, or was stopped
Exceptions thrown by the callback are swallowed and do not interrupt dispatch
LFAgent.askUser(options): Promise<{ askId, allowMultiple, allowCustom, selectedIds, selectedLabels, customText }>Pop a floating question card over the agent panel (NOT embedded in the session timeline) and await the user's answer; instance-independent, so custom tool handlers can call it directly (see "askUser(options) parameters" below)
send() reports canonical billing as usage.points, usage.costUnit === 'points', and usage.pointsSource; mixed actual/fallback turns use 'mixed'. Top-level estimatedCostCny = usage.points / 200 is deprecated compatibility only.
create(options) parameters
| Field | Type | Required | Description |
|---|---|---|---|
kind / mode | 'native' / 'custom' | no | mode is the legacy alias and also works at runtime (kind ?? mode); if both appear and differ, the call is rejected with LFAGENT_INVALID_OPTIONS. Default 'native': reuses the full plugin agent (including the terminal, so it may call plugincmd again recursively). 'custom': fully self-defined. Only 'native' / 'custom' are accepted; any other value (e.g. 'smart') is rejected with LFAGENT_INVALID_OPTIONS instead of silently falling back to native |
systemPrompt | string | required for custom | Full replacement system prompt; the framework injects nothing else. Native must not declare it |
tools | { name, description?, parameters?, handler }[] | no | description/parameters are optional at runtime (parameters defaults to { type: 'object', properties: {} }); marketplace review requires name/description/parameters/handler on every declared tool. Both kinds support page-side tools; native adds them on top of the full built-in set. Add-only — a built-in name collision is rejected with LFAGENT_INVALID_OPTIONS (built-in tools cannot be modified or removed); a missing name, a missing handler, or a duplicate tool name is rejected with LFAGENT_INVALID_OPTIONS too. parameters is an OpenAI function-calling JSON schema; handler(params, { signal, toolCallId }) runs in the current web page and returns a string or { output, isError } — when returning an object, put the content in its output field; any other object shape (such as a raw data object) yields empty output, so JSON.stringify data first. If the handler throws, error.message becomes the output and isError is set; the signal fires when the run is aborted. |
builtinTools | string[] | no | custom only (ignored by native): built-in tool whitelist (e.g. ['read_file']); unknown tool names are silently ignored. The control tools think / respond / attempt_completion / summarize_task are always kept; ask_followup_question is always excluded (LFAgent sessions are read-only, so nobody could answer a follow-up — call LFAgent.askUser from a custom tool instead). Whitelisting execute_command / execute_run_script auto-includes their read-only help companions get_command_help / get_builtin_library_help |
model | string | no | Model id, or 'inherit' (default): the main Agent's model for its current run when it has one in flight, otherwise the IDE's current chat model; re-resolved on every send (status().resolvedModel shows the current result). A nested LFAgent does not inherit its parent LFAgent's model. Model ids are not validated by create |
reasoningEffort | 'inherit' / 'low' / 'medium' / 'high' / 'xhigh' | no | Thinking effort. 'inherit' (default) follows the user's unified setting from the model dropdown (re-read on every send); a pinned level applies the same scale to every model — vendor parameter differences are mapped by the gateway and models without thinking capability ignore it, so no per-model branching is needed. Values are case-insensitive, surrounding whitespace is ignored, and aliases are accepted: none / minimal → low, max / x-high / extra_high → xhigh. Any other value is rejected with LFAGENT_INVALID_OPTIONS. status() returns both the normalized requested reasoningEffort and the resolvedReasoningEffort actually sent. Not part of marketplace static review |
destroyOnEnd | boolean | no | Auto-destroy the session (and close its tab) when a send settles (naturally or forcibly), after which instance.sessionId becomes ""; default false |
Strict marketplace static-review rules
- Marketplace code must call
LFAgent.create({ ... })directly with exactly one object-literal config ({}is fine); zero-argument calls, aliases, indirect calls, and dynamic configs cannot be reviewed kindor legacymodemust statically resolve tonative/customand agree when both appear; custom requires a non-empty staticsystemPrompt, while native forbids it- Every declared tool must contain
name/description/parameters/handler; the first three must be literals or recursively statically evaluable, never-reassignedconstbindings (stable compiler-emitted var is compatibility-only), and names must match[A-Za-z0-9_.-]{1,64}uniquely - Static string concatenation, templates with static substitutions, and JSON-literal objects / arrays / primitives are allowed and are extracted and server-sealed
- Generate stable
constsource; stablevarexists only for esbuild-lowered compatibility and should not be handwritten. Calls/await, network / storage / environment reads, computed properties, spread, and cycles are forbidden - Indirect, optional, computed, or aliased LFAgent.create access is forbidden. Reviewed tool arrays/objects must not be mutated or aliased, nor passed to unknown functions; sensitive constants must not be reused outside the LFAgent declaration's reviewed fields
- Do not forge
__lfReviewed,authorPack, orlf-author-pack-v1. Limits: 32 agents, 64 tools per agent, 512 author entries, 65536 systemPrompt chars, 4096 description chars, and 32768 UTF-8 bytes of canonical parameters JSON handleris execution code rather than model schema and may keep normal dynamic page logic, closures, and async work- Author prompts or schemas that cannot be extracted strictly are rejected at review time and never downgraded to plaintext. Compile, liveDebug, run <project-file>, and run -c apply the same static rule whenever agent is enabled and report source diagnostics. Local/liveDebug still use the v2 development transport, but cannot bypass static extractability
askUser(options) parameters
A single/multi choice question popup. By default a built-in "Other" option (reserved id __custom__) is appended after the fixed options: selecting it reveals a text input for a free-form answer. Concurrent questions — even from different plugincmd invocations — do not stack extra popups; they queue into the same popup whose counter goes from 1/1 to 1/2, advancing to the next question after each answer. The popup cannot be dismissed: the user must answer. The only exception is when the owning plugincmd invocation settles or the signal aborts, which withdraws the question automatically.
| Field | Type | Required | Description |
|---|---|---|---|
question | string | yes | Question body (rejected with LFAGENT_INVALID_OPTIONS when empty) |
title | string | no | Popup title |
options | Array<string / { id?, label }> | yes | Choice list (1–24 items, labels must be non-empty); string items are equivalent to { id: '<index>', label } with ids defaulting to '1', '2', …; ids must be unique; the id '__custom__' is reserved for the built-in Other option. Any violation is rejected with LFAGENT_INVALID_OPTIONS |
allowMultiple | boolean | no | true = multi-select (at least one required); default false = single-select |
allowCustom | boolean | no | Default true: append the built-in "Other" option whose selection reveals a text input; the typed text is returned as customText. Pass false when only the fixed options are acceptable |
signal | AbortSignal | no | An extra cancellation source; the owning plugincmd call's ctx.signal already cascades automatically, so you don't need to pass it. Any abort withdraws the question from the popup queue and rejects with LFAGENT_ABORTED (original reason in error.cause); a signal that is already aborted at call time rejects immediately without showing the question |
Resolves with { askId, allowMultiple, allowCustom, selectedIds, selectedLabels, customText }; single-select always yields exactly one entry in selectedIds. When the Other option was chosen, selectedIds contains '__custom__', the matching selectedLabels entry is the user's typed text, and customText carries the same text (empty string otherwise).
Lifecycle & concurrency
- An instance lives and dies with the plugincmd invocation that created it: when the call settles (result / timeout / Ctrl+C / disconnect), every run it created is force-stopped, and destroyOnEnd instances have their sessions destroyed too. Omitting
--timeoutonplugincmd callmeans unlimited (the default), so long tasks just keep the default; handlers can readctx.timeoutMs(0 = unlimited) to detect a time-boxed call at entry and refuse it - Instance methods (send / status / stop / destroy / getTranscript) are bound to the lease of the plugincmd call (or terminal run process) that created the instance and only work while it is alive; other calls running concurrently in the same page instance do not affect it. Once the creating call has ended, instance methods are rejected with
LFAGENT_CALL_MISMATCH(orWORKSPACE_LEASE_REQUIREDif no lease is active at all).LFAgent.create/LFAgent.askUserbind to the "current" call instead — when several plugincmd calls overlap in the same page instance, they bind to the most recently started one (and are cleaned up when that call settles). Avoid letting commands that create agents or call askUser overlap concurrently - Concurrency is async on the same event loop (the subagent mechanism), not real threads; CPU-bound work still blocks
- Every LFAgent session owns an isolated background terminal:
execute_command/execute_run_scriptrun in the session's own terminal, with cwd, environment variables, and the foreground queue fully isolated from the main agent terminal and other LFAgent sessions (workspace files still sync in real time). It never waits on the main agent terminal that hosts the originating plugincmd, and it is reclaimed automatically when the session is deleted or the invocation settles - LFAgent sessions are read-only in the IDE: no composer,
agent sendand rollback are rejected; context viewing/exporting works normally. Sessions never appear in the history list and no tab opens automatically — their only entry point is the collapsible "Subagents" panel above the spawning session's composer (a tree of parent-child spawning; nested spawns nest under their parent entry), where you can stop a run, open the orange tab to watch, or — once the whole subtree has stopped — delete with confirmation (cascades to every descendant). The shell's agent session commands can delete them too - Rollback semantics: workspace file changes made by an LFAgent are journaled against the session that issued the plugincmd call — rolling that session back to the plugincmd node (or earlier) also reverts every file change the LFAgent produced
- One send per instance at a time: sending again before the previous send finishes is always rejected with
LFAGENT_BUSY(guarded on both the page side and the IDE side)
Error codes
| Code | Meaning |
|---|---|
WORKSPACE_LEASE_REQUIRED | Plugin source outside a plugincmd invocation, or the command did not declare workspace: ['agent'] (temporary terminal run scripts hold a run lease automatically and never hit this) |
WORKSPACE_LEASE_EXPIRED | The lease was already revoked or expired when the request reached the background (a race with the call settling, timing out, or being aborted) |
WORKSPACE_SCOPE_DENIED | The background lease check found no agent scope, or the request's terminal session differs from the one the lease is bound to |
WORKSPACE_UNAUTHORIZED | A published app lacks the LFRuntime.workspace permission: plugincmd call exits with code 126 and an error output starting with WORKSPACE_UNAUTHORIZED:; the invoke is never delivered and the handler never runs (it never appears as an error.code in the page) |
LFAGENT_BUSY | The previous send on this instance has not finished |
LFAGENT_INVALID_OPTIONS | Invalid arguments: create with a kind other than native / custom or conflicting kind/mode, custom without systemPrompt, a tool with no name / no handler / a duplicate name / a built-in name, or an invalid reasoningEffort; empty send text; an invalid getTranscript include; askUser with an empty question, or options that are empty / more than 24 / have an empty label / a duplicate id / the reserved id __custom__ |
LFAGENT_CALL_MISMATCH | An instance method was called after the plugincmd invocation (or terminal run process) that created the instance had ended |
LFAGENT_NOT_FOUND | The IDE no longer knows the instance (e.g. its instance table was lost when the IDE page reloaded) |
LFAGENT_SESSION_GONE | The instance has not sent yet or its session was destroyed (getTranscript) |
LFAGENT_SESSION_UNAVAILABLE | On send, the session body could not be loaded back from the workspace, so the run is refused to avoid losing history |
LFAGENT_ABORTED | send / askUser was aborted (ctx.signal, options.signal, AbortSignal.timeout, Ctrl+C, …), or its signal was already aborted at call time (no run starts / no question is shown); the original reason is in error.cause |
LFAGENT_INVOKE_SETTLED | The owning plugincmd invocation settled (pending askUser questions are withdrawn) |
LFAGENT_TRANSPORT_UNAVAILABLE | No channel to the IDE could be opened: this world has no extension messaging (e.g. the MAIN world of run --tab without a terminal relay), or this script instance's USER_SCRIPT world lost its messaging configuration (after the app was disabled/re-enabled, liveDebug was started for the same project, etc., while plugincmd can still reach the stale instance) — reload the host page or restart liveDebug / re-run the script |
LFAGENT_TRANSPORT_LOST | The channel to the extension background dropped before the request finished: the extension reloaded or its background service worker restarted, the terminal run's host relay disconnected or never answered, a message could not be delivered, etc. A plugincmd call settling does not trigger it by itself (settling shows up as LFAGENT_ABORTED or LFAGENT_INVOKE_SETTLED) |
Check error.code rather than parsing error.message. Requests other than send / askUser (create / stop / destroy / status / getTranscript) that get no IDE answer within 60 seconds are rejected with a timeout error that carries no code.
Example
// ==UserScript==
// @name Data Analyst Command
// @match https://example.com/*
// @require agent
// ==/UserScript==
LFRuntime.cli.register({
name: "analyze",
brief: "Spawn a sub-agent to analyze the current page data",
workspace: ["agent"], // Declare the agent scope to use LFAgent inside the handler
async handler(argv) {
// native: reuse the full plugin agent and inherit the caller’s model
const agent = await LFAgent.create({ kind: "native", destroyOnEnd: false });
// In app source (liveDebug / installed) LFRuntime.print writes to the host page's console (view it with diag
// under liveDebug), not the terminal; plugincmd shows the handler's return value in the terminal.
// Always write LFRuntime.print in full — a bare print() is window.print()
const stop = agent.onProgress((event) => LFRuntime.print("progress", JSON.stringify(event)));
try {
// Fall back to a default instruction when no arguments are given, otherwise the
// empty text is rejected with LFAGENT_INVALID_OPTIONS; ctx.signal cascades
// automatically, so aborting the command also stops the run
const task = argv.join(" ") || "Summarize the main data on this page";
const result = await agent.send(task);
return `${result.text}\n(tokens: ${result.usage.totalTokens}, points: ${result.usage.points})`;
} finally {
stop();
}
},
});
// Example stub: replace with your own page-reading logic
const readSkuFromPage = (sku) => ({ sku, title: document.title, url: location.href });
// Create custom agents inside a plugincmd handler with the agent workspace scope too
LFRuntime.cli.register({
name: "audit-sku",
workspace: ["agent"],
async handler(argv) {
const custom = await LFAgent.create({
kind: "custom",
systemPrompt: "You are a terse SKU auditor. Use the lookup_sku tool.",
model: "inherit",
destroyOnEnd: true,
tools: [{
name: "lookup_sku",
description: "Look up a SKU record on this page",
parameters: { type: "object", properties: { sku: { type: "string" } }, required: ["sku"] },
// Return a string; an object must take the form { output, isError }
handler: async ({ sku }) => JSON.stringify(readSkuFromPage(sku)),
}],
});
// options.signal is an extra cancellation source; here it caps this send at 2 minutes
const answer = await custom.send(`Audit SKU ${argv[0] || "ABC-123"}`, { signal: AbortSignal.timeout(120000) });
return answer.text;
},
});When calling LFAgent directly from a temporary terminal run script, write LFRuntime.print(...) in full to print to the terminal — a bare print() is window.print() (the browser print dialog).