Scheduled work on this computer (spec 3.5.0): Accept scheduled work, scheduled turns, the watch command; team_add names the sub-manager
ci / check (push) Waiting to run

remote.scheduled (lembas service scheduled on|off, lembas remote scheduled on|off) said as
limits.scheduled; a scheduled session runs in the schedule's mode (Edit when none, Plan refused,
lowered to max_mode), reports ok/silent/failed/cancelled and the final reply, waits the
schedule's wait on approvals and offers no question, plan card or show. _lembas/monitor judges
the watch command by the mode (ask is refused) and hashes the output as it streams.
Conformance areas scheduled, silent and monitor run here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 12:16:54 +00:00
1 parent 788db0ff4d
commit d111cad22e
15 files changed
+636 -25

No files matched your search

+29
View File
@@ -4,6 +4,35 @@ All notable changes to LLeMbas CLI. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/).
## [Unreleased]
Harness spec 3.5.0: scheduled work on a linked computer.
### Added
- **Accept scheduled work.** A new switch, off by default: `lembas service scheduled on` (or
`lembas remote scheduled on`; `remote.scheduled` in the global config). On, the schedules you
make on your LLeMbas server may run on this computer as agent turns, in the folders remote work
may use. `lembas remote list --json` says `scheduled`; the server is told at once (the link
dials again once nothing is working).
- **Scheduled turns.** A run is an agent session in the schedule's mode — Edit unless the schedule
says Auto, never Plan, and never beyond `remote.max_mode` — with the schedule's Subagents
Min/Max. Its answer reports the run: `ok`, `silent` (the reply said `[SILENT]` on its first or
last line), `failed` or `cancelled`, and the final reply. A switch turned off is a fixed refusal
the server shows as it is.
- **Approvals in a scheduled run** go to you through the server (a notification on your phone)
and wait the schedule's wait — 30 minutes unless it says otherwise — then count as denied, and
the model is told so. Questions and plans have nobody to answer in a scheduled run.
- **Watch commands** (monitor mode): the server asks this computer to run a schedule's watch
command in its folder, with a timeout, and gets back a hash of the output and a short excerpt;
the model runs only when the output changed. A command the schedule's mode would ask about is
refused rather than run.
### Changed
- `team_add` under a sub-manager says where the report goes: "Its report goes to <sub-manager>
when it finishes", not "comes to you".
## [2.0.0-beta.6] — 2026-11-10
Harness spec 3.4.0 (spec commits 4b8786b, b66891e): one progress vocabulary for every client, and
+4
View File
@@ -1075,6 +1075,10 @@
"description": "Let the web UI open a terminal here (LLeMbas's terminal panel on a chat on this device): a login shell as this user, in a directory remote sessions may use. Default false.",
"type": "boolean"
},
"scheduled": {
"description": "Accept scheduled work (harness acp.md, spec 3.5.0): let your schedules on the LLeMbas instance run here as agent turns, in the folders remote work may use, in the schedule's mode (Edit unless it says Auto) lowered to max_mode. Default false. lembas service scheduled on|off.",
"type": "boolean"
},
"terminal_integration": {
"description": "Shell integration in those terminals (bash, zsh, fish): after your own startup files, the shell marks each prompt, command and exit status (OSC 133) and its directory (OSC 7), so the web UI shows the directory, a mark beside each finished command, and can jump between commands. false: the shell starts as it is. Default true.",
"type": "boolean"
+101 -10
View File
@@ -44,6 +44,7 @@ import type { Hub } from "./hub.ts"
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
import { isNote } from "../session/prefix.ts"
import { deniedText, finalReply, judgeMonitor, monitorTimeout, permissionFor, refusalText, runMonitor, runOutcome, scheduledMode, type ScheduledTurn } from "./scheduled.ts"
export const ACP_VERSION = 1
/** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
@@ -68,6 +69,8 @@ export interface Limits {
/** `remote.enabled`, where the limits are the device's (the link, the service's hub). False:
* nothing new is opened or shared, whatever the roots say. Absent: not judged here. */
enabled?: boolean
/** Accept scheduled work (`remote.scheduled`, spec 3.5.0), with remote work on. Absent: no. */
scheduled?: boolean
/** The link a terminal holds for /remote on a machine whose remote work is off: only
* the sessions shared from that terminal can be reached; the web UI opens none of its own. */
sharedOnly?: boolean
@@ -180,6 +183,9 @@ export interface Session {
turns: TurnLog<PromptResult>
/** The command set last sent to the client, to send it again only when it changed. */
commands?: string
/** The scheduled turn it runs now (spec 3.5.0): its mode is the ceiling, its approvals wait
* the schedule's wait and say so, and nobody answers a question or a plan. */
scheduled?: ScheduledTurn
}
/** What `session/prompt` answers. */
@@ -279,6 +285,10 @@ export const CAPABILITIES = [
"progress",
"tree",
"agents",
// Harness spec 3.5.0: scheduled turns (`scheduled` in session/new and session/prompt, the run's
// outcome in the answer, `scheduled` on an approval) and `_lembas/monitor`. Whether this device
// accepts them now is `limits.scheduled`.
"scheduled",
] as const
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
@@ -287,7 +297,7 @@ type AskRequest = Parameters<Asker["ask"]>[0]
/** `session/request_permission`'s params for an approval — the same card whichever process asks
* (the hub for a session a terminal runs). `patient`: a person may answer at the terminal
* too, so the instance waits rather than timing the card out. */
export function permissionParams(sessionId: string, req: AskRequest, o: { patient?: boolean; toolCallId?: string } = {}) {
export function permissionParams(sessionId: string, req: AskRequest, o: { patient?: boolean; toolCallId?: string; scheduled?: ScheduledTurn } = {}) {
const offerAlways = !req.request.alwaysAsk
return {
sessionId,
@@ -314,6 +324,9 @@ export function permissionParams(sessionId: string, req: AskRequest, o: { patien
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
// Spec 3.4.0 (capability tree): a subagent of the tree whose call only the person can allow.
...(req.source?.subagent ? { subagent: req.source.subagent } : {}),
// Spec 3.5.0 (capability scheduled): a scheduled turn's call — the client puts it to the
// person where they are, and the device waits the schedule's wait.
...(o.scheduled ? { scheduled: { schedule_id: o.scheduled.scheduleId, run_id: o.scheduled.runId, wait_s: Math.round(o.scheduled.waitMs / 1000) } } : {}),
},
},
}
@@ -480,6 +493,7 @@ export class AcpAgent {
peer.handle("_lembas/team/transcript", (p) => this.tree("team_transcript", p))
peer.handle("_lembas/team/findings", (p) => this.tree("team_findings", p))
peer.handle("_lembas/team/stop", (p) => this.tree("team_stop", p))
peer.handle("_lembas/monitor", (p) => this.monitor(p))
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
@@ -528,7 +542,7 @@ export class AcpAgent {
lembas: {
protocol: this.protocol,
harness_spec: HARNESS_VERSION,
limits: this.limits ? { roots: this.limits.roots, max_mode: this.limits.maxMode } : null,
limits: this.limits ? { roots: this.limits.roots, max_mode: this.limits.maxMode, scheduled: this.limits.scheduled === true } : null,
// The login's connection: a `model` event's ref `<connection>/<name>` is the
// instance's own model `<name>`; any other ref is one only this device has.
connection: this.instanceConnection ?? null,
@@ -587,12 +601,15 @@ export class AcpAgent {
const cwd = typeof p?.cwd === "string" ? expandHome(p.cwd) : ""
if (!cwd || !isAbsolute(cwd)) throw new RpcError(INVALID_PARAMS, "cwd must be an absolute path")
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw new RpcError(INVALID_PARAMS, `${cwd} is not a directory on this machine`)
// Spec 3.5.0: a scheduled session is judged first — off is the same fixed refusal whatever else.
const scheduled = this.judgeScheduled(p?._meta?.lembas?.scheduled)
const refused = this.refusal(cwd)
if (refused) throw new RpcError(INVALID_PARAMS, refused)
if (refused) throw new RpcError(INVALID_PARAMS, refused, scheduled ? { reason: "folder" } : undefined)
const L = this.limits
const wanted = asMode(p?._meta?.mode) ?? undefined
const mode = L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog() }
// A scheduled session runs in the schedule's mode, never `_meta.mode`.
const mode = scheduled ? scheduled.mode : L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog(), ...(scheduled ? { scheduled } : {}) }
// No model-made title: the client names its own conversations (LLeMbas has its chat's), and a
// title request would be a second call the account pays for, for nothing.
// The chat's model and effort: `lembas_model` is the instance's own name for it, the
@@ -778,7 +795,8 @@ export class AcpAgent {
* `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */
private equipShow(s: Session) {
const ctx = s.app.engine.o.toolCtx
if (!this.shows) return void delete ctx.show
// Nobody is looking at a scheduled run (spec 3.5.0): show is not offered there.
if (!this.shows || s.scheduled) return void delete ctx.show
ctx.show = async (req, callId) => {
if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" }
const r = (await this.peer.request("_lembas/show", { sessionId: s.id, toolCallId: callId ?? "", ...req }, 30_000)) as { shown?: unknown; reason?: unknown } | undefined
@@ -994,12 +1012,18 @@ export class AcpAgent {
// A turn id this session has taken already: not run again — the original's answer,
// now or when it is done.
if (s.turns.has(turnId)) return s.turns.run(turnId, () => Promise.reject(new Error("unreachable")))
// Spec 3.5.0: a scheduled turn is judged again (the setting may have been turned off since the
// session was opened); a prompt without `scheduled` is the person's, in the run's agent chat.
const scheduled = this.judgeScheduled(p?._meta?.lembas?.scheduled)
this.takeContext(s, p)
const prepared = preparePrompt(s.app, s.id, p.prompt, { roots: this.limits?.roots })
// Working already: the prompt waits for the turn to end and runs next. It used to be
// refused, and a web UI that had misjudged the session as idle lost its message to an error.
const run = async (started: () => void): Promise<PromptResult> => {
s.prompting = true
// The turn that runs now says whether the session is a scheduled run's (it may wait behind another).
if (scheduled) s.scheduled = scheduled
else delete s.scheduled
started()
try {
return await this.runPrepared(s, prepared, turnId)
@@ -1023,6 +1047,8 @@ export class AcpAgent {
* the expanded prompt with its attachments, under the command's model and mode for this turn. */
private async runPrepared(s: Session, prepared: ReturnType<typeof preparePrompt>, turnId: string): Promise<PromptResult> {
const app = s.app
const turn = s.scheduled
if (turn) return this.runScheduled(s, prepared, turnId, turn)
const done = (meta: Record<string, unknown>): PromptResult => ({ stopReason: "end_turn", _meta: { lembas: { turnId, ...meta } } })
if (prepared.kind === "message") {
app.bus.emit({ type: "notice", message: `/${prepared.name}: ${prepared.text}` })
@@ -1068,6 +1094,68 @@ export class AcpAgent {
}
}
/** A scheduled request's settings as this device runs them (spec 3.5.0), or undefined when the
* request is not scheduled; a refusal is thrown as -32602 with `data.reason`. */
private judgeScheduled(meta: any): ScheduledTurn | undefined {
if (meta === undefined || meta === null) return undefined
const m = typeof meta === "object" ? meta : {}
const L = this.limits
const judged = scheduledMode({ accept: L?.scheduled === true && L.enabled !== false && !L.sharedOnly, mode: m.mode, max_mode: L?.maxMode ?? "edit", approval_wait_s: m.approval_wait_s })
if ("refused" in judged) throw new RpcError(INVALID_PARAMS, refusalText(judged.refused as "scheduled_off" | "plan"), { reason: judged.refused })
const str = (v: unknown) => (typeof v === "string" ? v.slice(0, 200) : "")
return { scheduleId: str(m.schedule_id), runId: str(m.run_id), title: str(m.title), mode: judged.mode, waitMs: judged.wait_s * 1000 }
}
/** One scheduled turn: the schedule's mode is the ceiling for the turn — a command in the prompt
* narrows it, never widens it — and the answer carries the run's outcome. */
private async runScheduled(s: Session, prepared: ReturnType<typeof preparePrompt>, turnId: string, turn: ScheduledTurn): Promise<PromptResult> {
const app = s.app
const meta = (extra: Record<string, unknown>, reason: string, text: string, error?: string) => ({
turnId,
...extra,
scheduled: runOutcome({ runId: turn.runId, reason, text, error }),
})
if (prepared.kind !== "run") {
// A scheduled run's text is the server's; a command that is only an action has no reply.
return { stopReason: "end_turn", _meta: { lembas: meta({ command: prepared.name }, "stop", "") } }
}
app.engine.mode = prepared.mode ? clampMode(prepared.mode, turn.mode) : turn.mode
let error: string | undefined
const off = app.bus.on((e) => {
if (e.type === "error") error = e.message
})
const prev = { model: app.engine.model.ref, effort: app.engine.effort }
try {
if (prepared.model) app.switchModel(prepared.model)
const reason = await app.turns.prompt(prepared.prompt, prepared.extra, prepared.shown, { turnId, attachments: prepared.attachments })
const unsent = app.engine.takeInbox()
return { stopReason: STOP[reason] ?? "end_turn", _meta: { lembas: meta(unsent.length ? { unsent } : {}, reason, finalReply(app.engine.messages), error) } }
} finally {
off()
if (prepared.model && app.engine.model.ref !== prev.model) {
app.switchModel(prev.model)
app.engine.effort = prev.effort
}
app.engine.mode = turn.mode
}
}
/** `_lembas/monitor` (spec 3.5.0): a schedule's watch command, run here only where the
* schedule's mode allows it without asking — a watch has nobody to ask. */
private async monitor(p: any) {
const turn = this.judgeScheduled({ mode: p?.mode, schedule_id: p?.schedule_id, run_id: p?.run_id })!
const cwd = typeof p?.cwd === "string" ? expandHome(p.cwd) : ""
if (!cwd || !isAbsolute(cwd)) throw new RpcError(INVALID_PARAMS, "cwd must be an absolute path", { reason: "folder" })
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw new RpcError(INVALID_PARAMS, `${cwd} is not a directory on this machine`, { reason: "folder" })
const refused = this.refusal(cwd)
if (refused) throw new RpcError(INVALID_PARAMS, refused, { reason: "folder" })
const command = typeof p?.command === "string" ? p.command.trim() : ""
if (!command) throw new RpcError(INVALID_PARAMS, "command must be a command line")
const judged = judgeMonitor(command, turn.mode, { ...permissionFor(cwd), cwd })
if ("refused" in judged) throw new RpcError(INVALID_PARAMS, judged.text, { reason: "permission" })
return runMonitor(command, cwd, monitorTimeout(p?.timeout_s))
}
/** `_lembas/session/status`: whether the session is working, and where it runs — asked
* after a restart or a dropped link, so the instance follows a turn still running instead of
* sending its prompt a second time. */
@@ -1370,7 +1458,8 @@ export class AcpAgent {
* (its time ran out, the link dropped) is taken down with `_lembas/permission/settled`. */
private asker(s: Session): Asker {
const agentOf = () => s.agent ?? this
const canAsk = () => agentOf().cards && !agentOf().peer.isClosed
// Nobody is at a scheduled run to answer a question or a plan (spec 3.5.0).
const canAsk = () => agentOf().cards && !agentOf().peer.isClosed && !s.scheduled
/** The client does not know the method after all: as if it had never been offered (and never again). */
const unknown = (a: AcpAgent, e: unknown) => {
if (!(e instanceof RpcError) || e.code !== METHOD_NOT_FOUND) return false
@@ -1382,15 +1471,17 @@ export class AcpAgent {
ask: async (req): Promise<AskReply> => {
// Whichever link holds the session now; none (dropped, not back yet) is nobody to ask.
const agent = agentOf()
const timeout = agent.limits?.approvalTimeoutMs ?? 0
const params = permissionParams(s.id, req)
// A scheduled turn's approval waits the schedule's wait (spec 3.5.0), and says so.
const turn = s.scheduled
const timeout = turn ? turn.waitMs : (agent.limits?.approvalTimeoutMs ?? 0)
const params = permissionParams(s.id, req, turn ? { scheduled: turn } : {})
try {
if (agent.peer.isClosed) throw new Error("not linked")
const answer = await agent.peer.request("session/request_permission", params, timeout || undefined)
return replyFrom(answer, !req.request.alwaysAsk)
} catch {
settle(agent, params.toolCall.toolCallId)
return { kind: "deny", feedback: "Nobody answered the approval in time, so it was refused." }
return { kind: "deny", feedback: turn ? deniedText(turn.waitMs) : "Nobody answered the approval in time, so it was refused." }
}
},
// Getters: whether there is anybody to ask is decided when the tool runs, by the link
+5 -2
View File
@@ -56,6 +56,9 @@ export function limitsFromConfig(o: { sharedOnly?: boolean } = {}): Limits & { e
get terminal() {
return r().terminal === true
},
get scheduled() {
return r().enabled === true && r().scheduled === true
},
get terminalIntegration() {
return r().terminal_integration !== false
},
@@ -65,7 +68,7 @@ export function limitsFromConfig(o: { sharedOnly?: boolean } = {}): Limits & { e
/** What the instance is told when the link opens (the hello, initialize's limits and
* capabilities): when it changes, the link is dialled again so the instance hears the new one. */
function announced(L: Limits & { enabled: boolean }): string {
return JSON.stringify([L.enabled, L.sharedOnly, L.roots, L.maxMode, L.requireTrust, L.terminal])
return JSON.stringify([L.enabled, L.sharedOnly, L.roots, L.maxMode, L.requireTrust, L.terminal, L.scheduled === true])
}
export function pickInstance(name?: string): Instance {
@@ -199,7 +202,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
version: VERSION,
harness_spec: HARNESS_VERSION,
device: { host: hostname(), platform: `${process.platform}-${process.arch}` },
limits: { roots: limits.roots, max_mode: limits.maxMode, require_trust: limits.requireTrust },
limits: { roots: limits.roots, max_mode: limits.maxMode, require_trust: limits.requireTrust, scheduled: limits.scheduled === true },
})
status("linked")
// The limits apply at once, each time they are looked at. What the instance was told at
+238
View File
@@ -0,0 +1,238 @@
// Scheduled work on a linked computer (harness acp.md, spec 3.5.0): what this device makes of a
// scheduled turn and of a monitor's watch command. The server decides when a schedule runs; the
// device decides whether (Accept scheduled work, `remote.scheduled`) and how far (the schedule's
// mode, never wider than Edit unless it says Auto, lowered to remote.max_mode). The pure rules are
// here, run by the conformance areas `scheduled`, `silent` and `monitor`; agent.ts wires them in.
import { spawn } from "node:child_process"
import { createHash } from "node:crypto"
import { join } from "node:path"
import { loadConfig } from "../config/load.ts"
import type { Mode, PermissionConfig } from "../config/schema.ts"
import { LOOP } from "../harness.ts"
import { DEFAULT_RULES, evaluate, toRules, type Rule } from "../permission/evaluate.ts"
import { BUILTIN_HARDLINE, hardlineRules, type HardlineRule } from "../permission/hardline.ts"
import { fillPrompt } from "../prompt/fill.ts"
import { findProject, trustOf } from "../project/root.ts"
import type { Message } from "../provider/types.ts"
export const SCHEDULED = LOOP.scheduled
const RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
/** A mode lowered to `max` (agent.ts clampMode, kept here so neither imports the other). */
const clampMode = (wanted: Mode, max: Mode): Mode => (RANK[wanted] < RANK[max] ? max : wanted)
/** Why a scheduled request is refused: `data.reason` of the -32602. */
export type Refusal = "scheduled_off" | "plan" | "folder" | "permission"
/** What a scheduled request says (`_meta.lembas.scheduled`, or `_lembas/monitor`'s params). */
export interface ScheduledMeta {
schedule_id?: string
run_id?: string
title?: string
mode?: unknown
approval_wait_s?: unknown
}
/** A scheduled turn's settings, once the device has judged it. */
export interface ScheduledTurn {
scheduleId: string
runId: string
title: string
mode: Mode
waitMs: number
}
/** The schedule's mode and approval wait as this device runs them, or why it refuses
* (conformance/scheduled.json). `accept`: remote.scheduled with remote work on. */
export function scheduledMode(o: { accept: boolean; mode?: unknown; max_mode: Mode; approval_wait_s?: unknown }): { mode: Mode; wait_s: number } | { refused: Refusal } {
if (!o.accept) return { refused: "scheduled_off" }
if (o.mode === "plan") return { refused: "plan" }
// An unknown word is the default, never anything wider (`unrestricted` is not read as auto here).
const chosen: Mode = (SCHEDULED.modes as string[]).includes(o.mode as string) ? (o.mode as Mode) : (SCHEDULED.default_mode as Mode)
return { mode: clampMode(chosen, o.max_mode), wait_s: approvalWait(o.approval_wait_s) }
}
/** The approval wait in whole seconds, clamped (loop.json scheduled.approval_wait_seconds). */
export function approvalWait(v: unknown): number {
const W = SCHEDULED.approval_wait_seconds
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : W.default
return Math.max(W.min, Math.min(W.max, n))
}
/** The fixed text of a refusal. */
export function refusalText(reason: "scheduled_off" | "plan"): string {
return reason === "plan" ? SCHEDULED.texts.plan : SCHEDULED.texts.off
}
/** The feedback a scheduled approval nobody answered gives the model. */
export function deniedText(waitMs: number): string {
return fillPrompt(SCHEDULED.texts.denied, { minutes: String(Math.max(1, Math.round(waitMs / 60_000))) })
}
// --- The sentinel --------------------------------------------------------------------------
const marks = (line: string) => line.trim().replace(/\.+$/, "").trim().toUpperCase() === SCHEDULED.sentinel
/** Whether a reply is silent: the sentinel on its first or last non-empty line, any case, every
* trailing full stop forgiven (conformance/silent.json; the server's `notify.is_silent`). */
export function isSilent(text: string): boolean {
const lines = (text ?? "")
.trim()
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
return lines.length > 0 && (marks(lines[0]!) || marks(lines[lines.length - 1]!))
}
/** The reply without any sentinel line, trimmed: what a preview shows. */
export function stripSentinel(text: string): string {
return (text ?? "")
.trim()
.split(/\r?\n/)
.filter((l) => !marks(l))
.join("\n")
.trim()
}
/** The text of the last assistant message: a turn's final reply. */
export function finalReply(messages: Message[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!
if (m.role === "user") return ""
if (m.role !== "assistant") continue
const text = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
if (text.trim()) return text
}
return ""
}
/** The run's outcome, for `session/prompt`'s `_meta.lembas.scheduled`. */
export function runOutcome(o: { runId: string; reason: string; text: string; error?: string }) {
let status: "ok" | "silent" | "failed" | "cancelled"
let error: string | null = null
if (o.reason === "cancelled") status = "cancelled"
else if (o.reason === "error") {
status = "failed"
error = o.error || "the turn failed"
} else if (!o.text.trim()) {
status = "failed"
error = SCHEDULED.texts.no_reply
} else status = isSilent(o.text) ? "silent" : "ok"
return { run_id: o.runId, status, text: o.text, error }
}
// --- The watch command ---------------------------------------------------------------------
/** `sha256:` and the hex SHA-256 of the output's bytes as read, a newline, and the exit status:
* the status after the bytes, so a stream is hashed as it comes (conformance/monitor.json). */
export function monitorHash(exit: number, output: Uint8Array | string): string {
const h = createHash("sha256")
h.update(typeof output === "string" ? Buffer.from(output, "utf8") : output)
h.update(`\n${exit}`)
return `sha256:${h.digest("hex")}`
}
/** The permission context a `bash` call in `cwd` would be judged in: the hardline, the default
* rules, the global rules and — in a trusted project — the project's (as app.ts builds them). */
export function permissionFor(cwd: string): { rules: Rule[]; hardline: HardlineRule[]; root: string; planDir: string; projectDir: string } {
const project = findProject(cwd)
const trusted = trustOf(project.root) === "trusted"
const loaded = loadConfig({ projectConfigDir: project.dir, trusted })
return {
rules: [...toRules(DEFAULT_RULES, "default"), ...loaded.permissions.flatMap((p, i) => toRules(p, loaded.permissionSources[i] ?? "global"))],
hardline: hardlineRules({ extra: loaded.config.hardline_extra, disable: loaded.config.hardline_disable }),
root: project.root,
planDir: join(project.dir, "plans"),
projectDir: project.dir,
}
}
/** Whether a watch command may run in `mode`: only where the mode allows it without asking
* (conformance/monitor.json `judge`). */
export function judgeMonitor(line: string, mode: Mode, ctx: { rules: Rule[]; hardline: HardlineRule[]; root: string; cwd?: string; planDir?: string; projectDir?: string }): { run: true } | { refused: "asks" | "denied"; text: string } {
const d = evaluate({ permission: "bash", class: "execute", patterns: [line], command: line, paths: [ctx.cwd ?? ctx.root] }, { mode, rules: ctx.rules, hardline: ctx.hardline, root: ctx.root, planDir: ctx.planDir, projectDir: ctx.projectDir })
if (d.action === "allow") return { run: true }
const reason = d.reason || (d.action === "ask" ? "it is not allowed without asking" : "refused")
if (d.action === "ask") return { refused: "asks", text: fillPrompt(SCHEDULED.texts.monitor_asks, { mode, reason }) }
return { refused: "denied", text: fillPrompt(SCHEDULED.texts.monitor_denied, { reason }) }
}
/** The conformance runner's judge: the default rules and the case's own as global rules. */
export function judgeCase(line: string, mode: Mode, extra?: PermissionConfig) {
const rules = [...toRules(DEFAULT_RULES, "default"), ...toRules(extra ?? {}, "global")]
return judgeMonitor(line, mode, { rules, hardline: BUILTIN_HARDLINE, root: "/project", planDir: "/project/.agent/plans", projectDir: "/project/.agent" })
}
export interface MonitorResult {
hash: string | null
exit: number | null
timed_out: boolean
excerpt: string
truncated: boolean
bytes: number
duration_ms: number
}
/** The watch command's timeout in seconds, defaulted and clamped. */
export function monitorTimeout(v: unknown): number {
const T = SCHEDULED.monitor.timeout_seconds
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : T.default
return Math.max(T.min, Math.min(T.max, n))
}
/** Run a watch command as `bash -c` in `cwd`, in its own process group, with no input; stop the
* group at the timeout. Everything streamed is hashed; the first output_max_bytes are kept. */
export function runMonitor(command: string, cwd: string, timeoutS: number, env: Record<string, string | undefined> = process.env): Promise<MonitorResult> {
const M = SCHEDULED.monitor
const started = Date.now()
return new Promise((resolve) => {
const child = spawn("bash", ["-c", `exec 2>&1\n${command}`], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], env })
const hash = createHash("sha256")
const kept: Buffer[] = []
let keptBytes = 0
let bytes = 0
let timedOut = false
const onData = (d: Buffer) => {
bytes += d.length
hash.update(d)
if (keptBytes < M.output_max_bytes) {
const piece = d.subarray(0, M.output_max_bytes - keptBytes)
kept.push(piece)
keptBytes += piece.length
}
}
child.stdout!.on("data", onData)
child.stderr!.on("data", onData)
const killGroup = (sig: NodeJS.Signals) => {
try {
process.kill(-child.pid!, sig)
} catch {}
}
const timer = setTimeout(() => {
timedOut = true
killGroup("SIGTERM")
setTimeout(() => killGroup("SIGKILL"), 2000).unref()
}, timeoutS * 1000)
let done = false
const finish = (code: number | null) => {
if (done) return
done = true
clearTimeout(timer)
const text = Buffer.concat(kept).toString("utf8")
const excerpt = text.slice(0, M.excerpt_chars)
const exit = timedOut || child.signalCode ? null : code
if (exit !== null) hash.update(`\n${exit}`)
resolve({
hash: exit === null ? null : `sha256:${hash.digest("hex")}`,
exit,
timed_out: timedOut,
excerpt,
truncated: bytes > Buffer.byteLength(excerpt, "utf8"),
bytes,
duration_ms: Date.now() - started,
})
}
child.on("close", (code) => finish(code))
child.on("error", () => finish(127))
})
}
+20 -3
View File
@@ -45,12 +45,16 @@ Usage:
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
lembas service install | uninstall | status | logs [-f] | run
work from a LLeMbas instance as a background service (remote:)
lembas service scheduled [on|off]
Accept scheduled work: your schedules may run here as agent
turns (remote.scheduled, off by default)
lembas remote list | add <dir>… | remove <dir>… | enable | disable
| mode <manual|edit|auto|plan> | terminal <on|off> [--json]
| mode <manual|edit|auto|plan> | terminal <on|off> | scheduled <on|off> [--json]
what the instance may do here (remote:): the directories it may
work in (add turns remote work on), the most permissive mode,
terminals; a running service applies a change within seconds
(--json: one object, {enabled, roots, max_mode, terminal})
(--json: one object, {enabled, roots, max_mode, terminal,
scheduled})
lembas mcp [list] connect the MCP servers and list their tools and prompts
lembas mcp auth <server> sign in to a remote MCP server (OAuth)
lembas mcp logout <server> forget a server's stored sign-in
@@ -286,7 +290,20 @@ async function main() {
if (sub === "status") return void console.log(service.status().join("\n"))
if (sub === "logs") process.exit(service.logs(argv.includes("-f") || argv.includes("--follow")))
if (sub === "run") process.exit(await service.run(argv[2]))
fail("usage: lembas service install | uninstall | status | logs [-f] | run [connection]", 2)
// Accept scheduled work (spec 3.5.0): the same switch as `lembas remote scheduled`.
if (sub === "scheduled") {
const { remoteCommand } = await import("./remote.ts")
const v = argv[2]
if (v === undefined || v === "--json") {
const { remoteState } = await import("./config/remote.ts")
const s = remoteState()
if (v === "--json") console.log(JSON.stringify({ scheduled: s.scheduled, enabled: s.enabled }))
else console.log(`accept scheduled work: ${s.scheduled ? "on" : "off"}${s.scheduled && !s.enabled ? " (but remote work is off, so nothing runs: lembas remote enable)" : ""}`)
process.exit(0)
}
process.exit(remoteCommand(["scheduled", ...argv.slice(2)]))
}
fail("usage: lembas service install | uninstall | status | logs [-f] | run [connection] | scheduled [on|off]", 2)
}
if (cmd === "run") {
+3
View File
@@ -62,6 +62,8 @@ export interface RemoteState {
roots: string[]
max_mode: Mode
terminal: boolean
/** Accept scheduled work (spec 3.5.0). */
scheduled: boolean
}
export function remoteState(b: RemoteBlock = remoteBlock()): RemoteState {
@@ -70,6 +72,7 @@ export function remoteState(b: RemoteBlock = remoteBlock()): RemoteState {
roots: (b.roots ?? []).map((r) => resolve(expandHome(r))),
max_mode: b.max_mode ?? "edit",
terminal: b.terminal === true,
scheduled: b.scheduled === true,
}
}
+1
View File
@@ -317,6 +317,7 @@ export const Config = z.strictObject({
approval_timeout: z.number().int().positive().optional().describe("Seconds an approval waits for an answer from the web before it counts as a denial (default 600)."),
require_trust: z.boolean().optional().describe("Only projects trusted on this machine (default true)."),
terminal: z.boolean().optional().describe("Let the web UI open a terminal here (LLeMbas's terminal panel on a chat on this device): a login shell as this user, in a directory remote sessions may use. Default false."),
scheduled: z.boolean().optional().describe("Accept scheduled work (harness acp.md, spec 3.5.0): let your schedules on the LLeMbas instance run here as agent turns, in the folders remote work may use, in the schedule's mode (Edit unless it says Auto) lowered to max_mode. Default false. lembas service scheduled on|off."),
terminal_integration: z.boolean().optional().describe("Shell integration in those terminals (bash, zsh, fish): after your own startup files, the shell marks each prompt, command and exit status (OSC 133) and its directory (OSC 7), so the web UI shows the directory, a mark beside each finished command, and can jump between commands. false: the shell starts as it is. Default true."),
})
.optional()
+13 -1
View File
@@ -142,7 +142,7 @@ export const LOOP: {
work: { write: string[]; execute: string[]; kept: string[] }
read_max: number
texts: Record<
| "added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended"
| "added" | "added_waiting" | "added_under" | "added_waiting_under" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended"
// Spec 3.4.0 (subagents.md §5–§13).
| "only_main" | "not_manager" | "wider" | "request_not_narrower" | "request_kind" | "requestable" | "not_delegated" | "not_requestable" | "requested" | "no_request" | "decided" | "allowed" | "refused" | "decision_allowed" | "decision_refused" | "no_parent" | "finished" | "has_ended" | "cycle" | "assigned" | "replaced" | "replace_brief" | "no_findings" | "noted" | "not_yours_findings" | "context_full" | "gone" | "request_body",
string
@@ -171,6 +171,18 @@ export const LOOP: {
whoami: { lines: Record<"who" | "parent" | "task" | "mode" | "request" | "model" | "limits" | "spawn_yes" | "spawn_no" | "context", string> }
findings: { header: string; item: string }
}
/** Scheduled work on a linked computer (spec 3.5.0, acp.md): the setting, the modes, the
* approval wait, the sentinel, the watch command's limits, the refusals. */
scheduled: {
setting: { key: string; label: string; hint: string }
modes: string[]
default_mode: string
approval_wait_seconds: { default: number; min: number; max: number }
sentinel: string
statuses: string[]
monitor: { timeout_seconds: { default: number; min: number; max: number }; excerpt_chars: number; output_max_bytes: number }
texts: Record<"off" | "plan" | "denied" | "monitor_asks" | "monitor_denied" | "no_reply", string>
}
/** Progress (spec 3.4.0, harness/progress.md): the phases, their texts, the line. */
progress: {
phases: string[]
+12 -2
View File
@@ -7,10 +7,11 @@
// lembas remote enable | disable [--json] remote.enabled
// lembas remote mode <manual|edit|auto|plan> remote.max_mode
// lembas remote terminal <on|off> remote.terminal
// lembas remote scheduled <on|off> remote.scheduled: Accept scheduled work (spec 3.5.0)
//
// Every change goes through the global config writer, which keeps the file's comments. A running
// `lembas service` reads the block again whenever it uses it, so a change applies to it within a few
// seconds, no restart (acp/link.ts). --json prints one object: {enabled, roots, max_mode, terminal}.
// seconds, no restart (acp/link.ts). --json prints one object: {enabled, roots, max_mode, terminal, scheduled}.
import { resolve } from "node:path"
import { ConfigError } from "./config/load.ts"
import { expandHome } from "./config/paths.ts"
@@ -18,7 +19,7 @@ import { coveredBy, realOrResolved, remoteBlock, remoteState, rootFor, type Remo
import { MODES, type Mode } from "./config/schema.ts"
import { updateGlobalConfig } from "./config/write.ts"
export const REMOTE_USAGE = `usage: lembas remote list | add <dir>… | remove <dir>… | enable | disable | mode <${MODES.join("|")}> | terminal <on|off> [--json]`
export const REMOTE_USAGE = `usage: lembas remote list | add <dir>… | remove <dir>… | enable | disable | mode <${MODES.join("|")}> | terminal <on|off> | scheduled <on|off> [--json]`
export interface Io {
out: (line: string) => void
@@ -34,6 +35,7 @@ function describe(s: RemoteState): string[] {
s.roots.length ? `roots:\n${s.roots.map((r) => ` ${r}`).join("\n")}` : "roots: (none: no directory accepts remote work)",
`max mode: ${s.max_mode}`,
`terminal: ${s.terminal ? "on" : "off"}`,
`accept scheduled work: ${s.scheduled ? "on" : "off"}`,
]
}
@@ -146,6 +148,14 @@ export function remoteCommand(argv: string[], io: Io = stdio): number {
return show()
}
if (sub === "scheduled") {
const v = rest[0]
if (rest.length !== 1 || (v !== "on" && v !== "off")) return io.err("usage: lembas remote scheduled <on|off>"), 2
write([[["scheduled"], v === "on"]])
if (v === "on" && block.enabled !== true) said.push("remote work is off: scheduled work needs it too — lembas remote add <dir> or enable")
return show()
}
io.err(REMOTE_USAGE)
return 2
}
+5 -2
View File
@@ -515,8 +515,11 @@ export class Team {
this.agents.set(member.name, member)
this.want(member)
this.fillSlots()
const vars = { name: member.name, role: member.role, size: this.size(), max: this.max, parallel: parallelFrom(this.o.capacity(member.model)) }
return { text: fill(member.state === "working" ? TEXTS.added : TEXTS.added_waiting, vars), added: member }
const vars = { name: member.name, role: member.role, size: this.size(), max: this.max, parallel: parallelFrom(this.o.capacity(member.model)), parent: parent.name }
// Spec 3.5.0: placed under a sub-manager, its report goes there — said so, not "to you".
const under = parent.name !== LEAD
const text = member.state === "working" ? (under ? TEXTS.added_under : TEXTS.added) : under ? TEXTS.added_waiting_under : TEXTS.added_waiting
return { text: fill(text, vars), added: member }
}
/** team_send (§5). */
+2 -2
View File
@@ -137,7 +137,7 @@ test("the device's limits: outside the roots, untrusted, a higher mode", async (
const root = project()
const limits: Limits = { roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true }
const { c, init } = await client(limits)
expect(init._meta.lembas.limits).toEqual({ roots: [root], max_mode: "edit" })
expect(init._meta.lembas.limits).toEqual({ roots: [root], max_mode: "edit", scheduled: false })
await expect(c.request("session/new", { cwd: tmpdir() })).rejects.toThrow("outside the directories")
const untrusted = join(root, "..", `ph-untrusted-${Date.now()}`)
mkdirSync(untrusted)
@@ -268,7 +268,7 @@ test("the terminal: a real shell through the link, only where remote.terminal sa
config(fake.url)
const root = project()
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show", "progress", "tree", "agents"])
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show", "progress", "tree", "agents", "scheduled"])
await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off")
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
+9
View File
@@ -34,6 +34,7 @@ import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.ts"
import { progressLine, progressText, type Progress } from "../src/session/progress.ts"
import { checkMode, requestRefusal, tighten, whoDecides, type Grant } from "../src/team/delegation.ts"
import { fillPrompt as fillText } from "../src/prompt/fill.ts"
import { isSilent, judgeCase, monitorHash, scheduledMode, stripSentinel } from "../src/acp/scheduled.ts"
type Case = Record<string, unknown>
@@ -50,6 +51,14 @@ function readableUnder(grants: Record<string, string> | undefined, path: string)
}
export const AREAS: Record<string, (c: Case) => unknown> = {
// Spec 3.5.0: scheduled work on a linked computer (src/acp/scheduled.ts).
scheduled: (c) => scheduledMode({ accept: c.accept === true, mode: c.mode, max_mode: c.max_mode as Mode, approval_wait_s: c.approval_wait_s }),
silent: (c) => ({ silent: isSilent(c.text as string), stripped: stripSentinel(c.text as string) }),
monitor: (c) => {
if (c.kind === "hash") return monitorHash(c.exit as number, c.output as string)
const r = judgeCase(c.line as string, c.mode as Mode, c.rules as PermissionConfig | undefined)
return "run" in r ? "run" : { refused: r.refused }
},
hardline: (c) => hardlineCommand(c.line as string, BUILTIN_HARDLINE)?.id ?? null,
split: (c) => splitCommand(c.line as string),
plain: (c) => plainCommands(c.line as string),
+3 -3
View File
@@ -37,7 +37,7 @@ test("list --json: one object, the defaults applied", () => {
const r = run("list", "--json")
expect(r.code).toBe(0)
expect(r.out).toHaveLength(1)
expect(r.json()).toEqual({ enabled: false, roots: [], max_mode: "edit", terminal: false })
expect(r.json()).toEqual({ enabled: false, roots: [], max_mode: "edit", terminal: false, scheduled: false })
// Without --json, lines a person reads.
const plain = run("list")
expect(plain.out.join("\n")).toContain("remote work: off")
@@ -54,7 +54,7 @@ test("add: absolute real paths, turns remote work on, skips duplicates and cover
const r = run("add", a, link, "--json")
expect(r.code).toBe(0)
// The link is stored as where it leads.
expect(r.json()).toEqual({ enabled: true, roots: [a, b], max_mode: "edit", terminal: false })
expect(r.json()).toEqual({ enabled: true, roots: [a, b], max_mode: "edit", terminal: false, scheduled: false })
// Again, and a directory inside one: nothing more.
const again = run("add", a, join(a, "sub"))
expect(again.code).toBe(0)
@@ -212,7 +212,7 @@ test("the service links with remote work off, says no roots and refuses everythi
// Turned on from outside, as Desktop does: it links again, saying the new roots, and serves.
run("add", a)
await until(() => hellos.length === 2, "a second hello")
expect(hellos[1].limits).toEqual({ roots: [a], max_mode: "edit", require_trust: true })
expect(hellos[1].limits).toEqual({ roots: [a], max_mode: "edit", require_trust: true, scheduled: false })
expect(states.some((s) => s.detail === "the remote settings changed; linking again")).toBe(true)
await ask("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 2 } } } })
expect((await ask("_lembas/directories", {})).result.places.map((p: any) => [p.path, p.accepted])).toEqual([[a, true]])
+191
View File
@@ -0,0 +1,191 @@
// Scheduled work on a linked computer (harness spec 3.5.0, acp.md): Accept scheduled work, a
// scheduled turn's mode, approvals that wait the schedule's wait, the run's outcome in the answer,
// and `_lembas/monitor`. A fake LLeMbas is the client; the model is a scripted endpoint.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { AcpAgent, type Limits } from "../src/acp/agent.ts"
import { Peer, type Transport } from "../src/acp/rpc.ts"
import { approvalWait, deniedText, monitorHash, runMonitor, SCHEDULED } from "../src/acp/scheduled.ts"
import { paths } from "../src/config/paths.ts"
import { remoteState } from "../src/config/remote.ts"
import { setTrust } from "../src/project/root.ts"
import { remoteCommand } from "../src/remote.ts"
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
function pair(): [Transport, Transport] {
const make = () => ({ msg: (_: string) => {}, end: () => {} })
const a = make()
const b = make()
const side = (me: typeof a, other: typeof a): Transport => ({
send: (t) => queueMicrotask(() => other.msg(t)),
onMessage: (fn) => (me.msg = fn),
onClose: (fn) => (me.end = fn),
close: () => (me.end(), other.end()),
})
return [side(a, b), side(b, a)]
}
function config(url: string) {
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${url}\n models: { f/m: {} }\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
}
function project(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "ph-sched-")))
Bun.spawnSync(["git", "init", "-q", dir])
setTrust(dir, "trusted")
return dir
}
const limitsFor = (root: string, extra: Partial<Limits> = {}): Limits => ({ roots: [root], maxMode: "auto", approvalTimeoutMs: 3000, requireTrust: true, enabled: true, scheduled: true, ...extra })
async function client(limits: Limits, answer?: (p: any) => unknown) {
const [ta, tb] = pair()
new AcpAgent(new Peer(ta), limits)
const c = new Peer(tb)
const asked: any[] = []
const settled: any[] = []
c.on("_lembas/permission/settled", (p) => void settled.push(p))
c.on("_lembas/event", () => {})
c.handle("session/request_permission", (p) => (asked.push(p), answer ? answer(p) : { outcome: { outcome: "selected", optionId: "once" } }))
const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 3 } } } })
return { c, init, asked, settled }
}
const sched = (extra: Record<string, unknown> = {}) => ({ schedule_id: "sched1", run_id: "run7", title: "Check", ...extra })
test("capability scheduled and limits.scheduled in initialize", async () => {
fake = fakeProvider([])
config(fake.url)
const root = project()
const on = await client(limitsFor(root))
expect(on.init._meta.lembas.capabilities).toContain("scheduled")
expect(on.init._meta.lembas.limits.scheduled).toBe(true)
const off = await client(limitsFor(root, { scheduled: false }))
expect(off.init._meta.lembas.capabilities).toContain("scheduled")
expect(off.init._meta.lembas.limits.scheduled).toBe(false)
})
test("off: a scheduled session, prompt and monitor are refused with the fixed text and data.reason", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "hi" })] }])
config(fake.url)
const root = project()
const { c } = await client(limitsFor(root, { scheduled: false }))
const err = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched() } } }).catch((e) => e)
expect(err.message).toBe(SCHEDULED.texts.off)
expect(err.data).toEqual({ reason: "scheduled_off" })
const m = await c.request("_lembas/monitor", { cwd: root, command: "true" }).catch((e) => e)
expect(m.data).toEqual({ reason: "scheduled_off" })
// An ordinary agent chat is not affected.
const s: any = await c.request("session/new", { cwd: root })
const p = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "go" }], _meta: { lembas: { scheduled: sched() } } }).catch((e) => e)
expect(p.data).toEqual({ reason: "scheduled_off" })
})
test("the mode: Edit when none chosen, Auto only when the schedule says it, Plan refused, _meta.mode ignored", async () => {
fake = fakeProvider([])
config(fake.url)
const root = project()
const { c } = await client(limitsFor(root))
const none: any = await c.request("session/new", { cwd: root, _meta: { mode: "auto", lembas: { scheduled: sched() } } })
expect(none.modes.currentModeId).toBe("edit")
const auto: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "auto" }) } } })
expect(auto.modes.currentModeId).toBe("auto")
const plan = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "plan" }) } } }).catch((e) => e)
expect(plan.message).toBe(SCHEDULED.texts.plan)
const { c: c2 } = await client(limitsFor(root, { maxMode: "manual" }))
const low: any = await c2.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "auto" }) } } })
expect(low.modes.currentModeId).toBe("manual")
// A folder outside the roots: the usual reason, marked.
const out = await c.request("session/new", { cwd: tmpdir(), _meta: { lembas: { scheduled: sched() } } }).catch((e) => e)
expect(out.data).toEqual({ reason: "folder" })
})
test("a scheduled turn reports ok, silent, and the final reply", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "All the waybread is wrapped." })] }, { chunks: [delta({ content: "Nothing new.\n[silent]." })] }])
config(fake.url)
const root = project()
const { c } = await client(limitsFor(root))
const s: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched() } } })
const r1: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "check" }], _meta: { lembas: { turnId: "run7", scheduled: sched() } } })
expect(r1._meta.lembas.scheduled).toEqual({ run_id: "run7", status: "ok", text: "All the waybread is wrapped.", error: null })
const r2: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "check" }], _meta: { lembas: { turnId: "run8", scheduled: sched({ run_id: "run8" }) } } })
expect(r2._meta.lembas.scheduled.status).toBe("silent")
expect(r2._meta.lembas.scheduled.run_id).toBe("run8")
// Sent again after a dropped link: the run's answer, not a second run.
const again: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "check" }], _meta: { lembas: { turnId: "run7", scheduled: sched() } } })
expect(again._meta.lembas.scheduled.text).toBe("All the waybread is wrapped.")
})
test("approvals in a scheduled turn carry `scheduled` and the schedule's wait; unanswered, the model reads the fixed text", async () => {
fake = fakeProvider([{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "rm -rf build" }))] }, { chunks: [delta({ content: "could not clean" })] }])
config(fake.url)
const root = project()
const { c, asked } = await client(limitsFor(root), () => ({ outcome: { outcome: "selected", optionId: "deny" } }))
const s: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "edit" }) } } })
const r: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "clean" }], _meta: { lembas: { turnId: "run9", scheduled: sched({ run_id: "run9", approval_wait_s: 90 }) } } })
expect(asked[0]._meta.lembas.scheduled).toEqual({ schedule_id: "sched1", run_id: "run9", wait_s: 90 })
expect(asked[0]._meta.lembas.patient).toBeUndefined()
expect(r._meta.lembas.scheduled.status).toBe("ok")
expect(deniedText(30 * 60_000)).toBe("Nobody answered this approval within 30 minutes, so it counts as denied.")
expect(approvalWait(undefined)).toBe(1800)
})
test("ask_user in a scheduled turn: nobody to ask", async () => {
const ASK = JSON.stringify({ questions: [{ header: "DB", question: "Which?", options: [{ label: "A" }, { label: "B" }] }] })
fake = fakeProvider([{ chunks: [toolCall(0, "q1", "ask_user", ASK)] }, { chunks: [delta({ content: "assumed A" })] }])
config(fake.url)
const root = project()
const { c } = await client(limitsFor(root))
let questioned = false
c.handle("_lembas/question", () => ((questioned = true), { outcome: "dismissed" }))
const s: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched() } } })
const r: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "pick" }], _meta: { lembas: { turnId: "r1", scheduled: sched() } } })
expect(questioned).toBe(false)
expect(r._meta.lembas.scheduled.text).toBe("assumed A")
})
test("_lembas/monitor: runs what the mode allows, refuses what it would ask about, hashes the output", async () => {
fake = fakeProvider([])
config(fake.url)
const root = project()
writeFileSync(join(root, "feed.txt"), "one\n")
const { c } = await client(limitsFor(root))
const r: any = await c.request("_lembas/monitor", { cwd: root, command: "cat feed.txt", mode: "edit" })
expect(r).toMatchObject({ exit: 0, timed_out: false, excerpt: "one\n", truncated: false, bytes: 4 })
expect(r.hash).toBe(monitorHash(0, "one\n"))
const asks = await c.request("_lembas/monitor", { cwd: root, command: "rm -rf build", mode: "edit" }).catch((e) => e)
expect(asks.data).toEqual({ reason: "permission" })
expect(asks.message).toContain("edit mode")
const plan = await c.request("_lembas/monitor", { cwd: root, command: "cat feed.txt", mode: "plan" }).catch((e) => e)
expect(plan.data).toEqual({ reason: "plan" })
})
test("runMonitor: a timeout stops the group and has no hash; a long output is hashed whole", async () => {
const root = project()
const slow = await runMonitor("echo start; sleep 5", root, 1)
expect(slow).toMatchObject({ hash: null, exit: null, timed_out: true })
expect(slow.excerpt).toBe("start\n")
const big = await runMonitor(`head -c ${SCHEDULED.monitor.output_max_bytes + 10} /dev/zero`, root, 10)
expect(big.bytes).toBe(SCHEDULED.monitor.output_max_bytes + 10)
expect(big.hash).toBe(monitorHash(0, new Uint8Array(SCHEDULED.monitor.output_max_bytes + 10)))
expect(big.truncated).toBe(true)
}, 20_000)
test("lembas remote scheduled on|off writes remote.scheduled, and --json says it", () => {
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "config.yaml"), "remote:\n enabled: true\n")
const out: string[] = []
expect(remoteCommand(["scheduled", "on", "--json"], { out: (l) => out.push(l), err: () => {} })).toBe(0)
expect(JSON.parse(out.at(-1)!).scheduled).toBe(true)
expect(remoteState().scheduled).toBe(true)
expect(remoteCommand(["scheduled", "off"], { out: () => {}, err: () => {} })).toBe(0)
expect(remoteState().scheduled).toBe(false)
expect(remoteCommand(["scheduled", "maybe"], { out: () => {}, err: () => {} })).toBe(2)
})