Compare commits
6
Commits
v2.0.0-beta.6
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb7800cc6e
|
||
|
|
a3e9a43dd6
|
||
|
|
a4027d5626
|
||
|
|
d111cad22e
|
||
|
|
788db0ff4d
|
||
|
|
e349d829a1
|
No files matched your search
@@ -4,6 +4,51 @@ All notable changes to LLeMbas CLI. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||||
[Semantic Versioning](https://semver.org/).
|
[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.
|
||||||
|
|
||||||
|
- **`/schedule`**: your schedules on the LLeMbas server from the terminal — the list with each
|
||||||
|
one's next run and last result, `new <words>` (the server works out the rule; you see what it
|
||||||
|
understood and the next runs before anything is made, and may have it run on this computer in
|
||||||
|
the current folder), `pause`, `resume`, `run` (now, without using up the next scheduled run) and
|
||||||
|
`history`. It uses this computer's sign-in; a server too old to allow that says so.
|
||||||
|
|
||||||
|
- **A notice when a reply finishes** — or when the agent waits for your answer — while the
|
||||||
|
terminal is not focused: the bell, and a desktop notification (OSC 9, in terminals that show
|
||||||
|
one) naming the session, never what the reply says. `notify: false` (or `/settings`) turns it
|
||||||
|
off.
|
||||||
|
- **`/rename <title>`** names this session — in `/sessions`, and in the web UI when it is shared.
|
||||||
|
- **`/search <words>`** finds a past session in this project by what was said in it (add `all`
|
||||||
|
for every project) and resumes the one you pick.
|
||||||
|
- **`/memory` shows the server's memory** when the server keeps it (`library: lembas`): its
|
||||||
|
entries beside this machine's files, each one to change or forget, through the server.
|
||||||
|
|
||||||
|
### 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
|
## [2.0.0-beta.6] — 2026-11-10
|
||||||
|
|
||||||
Harness spec 3.4.0 (spec commits 4b8786b, b66891e): one progress vocabulary for every client, and
|
Harness spec 3.4.0 (spec commits 4b8786b, b66891e): one progress vocabulary for every client, and
|
||||||
|
|||||||
+35
-1
@@ -22,7 +22,7 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
| `permission/defaults.json` | The rules every session starts from |
|
| `permission/defaults.json` | The rules every session starts from |
|
||||||
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
|
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
|
||||||
| `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
|
| `models/schema.json` | Model metadata: context, output limit, efforts and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
|
||||||
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `subagents` — the model refusals, what names a model, the setting's label; `team` — a team's size, names, marks, refusals and texts; `progress` — the phases' texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes |
|
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `subagents` — the model refusals, what names a model, the setting's label; `team` — a team's size, names, marks, refusals and texts; `progress` — the phases' texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes; `scheduled` (3.5.0) — scheduled work on a linked computer: the setting, the modes, the approval wait, the sentinel, the watch command's limits and the refusals |
|
||||||
| `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory |
|
| `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory |
|
||||||
| `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards; the prefix that stays still; reasoning sent back during its turn; no absolute paths in the prompt |
|
| `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards; the prefix that stays still; reasoning sent back during its turn; no absolute paths in the prompt |
|
||||||
| `team.md` | Teams: a lead and the members it adds, the size setting, the room, who receives what, reports, a manager's refusals, capacity, stopping |
|
| `team.md` | Teams: a lead and the members it adds, the size setting, the room, who receives what, reports, a manager's refusals, capacity, stopping |
|
||||||
@@ -71,6 +71,40 @@ page *Harness-parity* in LLeMbas CLI's wiki.
|
|||||||
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
|
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
|
||||||
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
|
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
|
||||||
|
|
||||||
|
## 3.5.0
|
||||||
|
|
||||||
|
Written ahead of both implementations; the server vendors it as written, both carry it in the same
|
||||||
|
round (`acp.md` *Scheduled work on a linked computer*, `loop.json` `scheduled`).
|
||||||
|
|
||||||
|
- **Scheduled turns on a linked computer.** A schedule may run each time as an agent turn on a
|
||||||
|
linked device, in a folder of it. The device must allow it: **Accept scheduled work**
|
||||||
|
(`remote.scheduled`, off by default; `lembas service scheduled on|off`), said to the server as
|
||||||
|
`limits.scheduled` and judged again at every request — off, every scheduled request is refused
|
||||||
|
with one fixed text. The server opens an agent session marked `scheduled` (schedule, run, mode,
|
||||||
|
approval wait) with the schedule's Min/Max as an agent chat's, and prompts it with the run's own
|
||||||
|
text. **The mode** is the schedule's — Manual, Edit or Auto, **Edit when none was chosen**, so never
|
||||||
|
wider than Edit unless the person picked Auto; Plan refused — lowered to `remote.max_mode`. The
|
||||||
|
answer reports the run: `status` `ok`, `silent` (the `[SILENT]` sentinel on the first or last
|
||||||
|
line, the server's rule), `failed` or `cancelled`, and the final reply's `text`. A device that is
|
||||||
|
away is waited for like a busy model, then the run is missed; a refusal fails the run.
|
||||||
|
- **Approvals in a scheduled turn** go to the person through the server (a phone notification with
|
||||||
|
the card), carry `scheduled` on `session/request_permission`, and wait the schedule's wait
|
||||||
|
(default 30 minutes) instead of `remote.approval_timeout`; then they count as denied and the turn
|
||||||
|
goes on. `ask_user` and `plan_submit` have nobody to answer; `show` is not offered.
|
||||||
|
- **Monitor mode, the device's half**: `_lembas/monitor` runs a schedule's watch command in its
|
||||||
|
folder, with a timeout, under the mode's permission rules — **a command the mode would ask about
|
||||||
|
is refused** — and answers the output's hash (`sha256:` over the bytes and then the exit status) and a
|
||||||
|
short excerpt; the server runs the model only when the hash changed, and records `unchanged`
|
||||||
|
otherwise.
|
||||||
|
- **Schedules over `/api/v2` with the device's token** (scope `link`): the CLI's `/schedule` lists,
|
||||||
|
creates from words (compile), pauses, resumes, runs now and reads history. A run may be
|
||||||
|
`unchanged`, may wait for the `device`; a schedule may say `runs_on` and `watch`.
|
||||||
|
- **`team_add` under a sub-manager** says where the report goes: `team.texts.added_under` and
|
||||||
|
`added_waiting_under` ("Its report goes to <parent> when it finishes").
|
||||||
|
- Capability `scheduled`. Conformance areas **`scheduled`** (the mode, the wait, the refusals),
|
||||||
|
**`silent`** (the sentinel and the stripped text) and **`monitor`** (the hash, and what a watch
|
||||||
|
command may run); `team` cases changed for `added_under`.
|
||||||
|
|
||||||
## 3.4.0
|
## 3.4.0
|
||||||
|
|
||||||
Written ahead of both implementations; the server vendors it as written, both carry it in the same
|
Written ahead of both implementations; the server vendors it as written, both carry it in the same
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.4.0
|
3.5.0
|
||||||
+174
-3
@@ -68,6 +68,7 @@ change to the protocol is a change here first, then in both projects.
|
|||||||
| `progress` | spec 3.4.0 (`progress.md`): the `phase` event; `progress` in `_lembas/session/status` |
|
| `progress` | spec 3.4.0 (`progress.md`): the `phase` event; `progress` in `_lembas/session/status` |
|
||||||
| `agents` | spec 3.4.0: the session's Subagents setting `{ min, max }` is read from `_meta.lembas.agents` on `session/new`, `session/prompt` and `_lembas/configure` (*Subagents in an agent chat*, below) |
|
| `agents` | spec 3.4.0: the session's Subagents setting `{ min, max }` is read from `_meta.lembas.agents` on `session/new`, `session/prompt` and `_lembas/configure` (*Subagents in an agent chat*, below) |
|
||||||
| `tree` | spec 3.4.0 (`subagents.md` §5–§13): the `team` event; `_lembas/team`, `_lembas/team/transcript`, `_lembas/team/findings`, `_lembas/team/stop`; `subagent` on `session/request_permission` |
|
| `tree` | spec 3.4.0 (`subagents.md` §5–§13): the `team` event; `_lembas/team`, `_lembas/team/transcript`, `_lembas/team/findings`, `_lembas/team/stop`; `subagent` on `session/request_permission` |
|
||||||
|
| `scheduled` | spec 3.5.0 (*Scheduled work on a linked computer*, below): `scheduled` in `_meta.lembas` of `session/new` and `session/prompt` read, and the run's outcome in the prompt's answer; `scheduled` on `session/request_permission`; `_lembas/monitor`; `limits.scheduled` in `_lembas/hello` and `initialize`. Listed by every build that has it, whatever the setting says: whether this device *accepts* scheduled work is `limits.scheduled` |
|
||||||
|
|
||||||
## Standard ACP, as used here
|
## Standard ACP, as used here
|
||||||
|
|
||||||
@@ -90,14 +91,15 @@ Result:
|
|||||||
"_meta": { "lembas": {
|
"_meta": { "lembas": {
|
||||||
"protocol": 3,
|
"protocol": 3,
|
||||||
"harness_spec": "3.0.0",
|
"harness_spec": "3.0.0",
|
||||||
"limits": { "roots": ["~/work"], "max_mode": "edit" },
|
"limits": { "roots": ["~/work"], "max_mode": "edit", "scheduled": false },
|
||||||
"connection": "example",
|
"connection": "example",
|
||||||
"capabilities": ["directories", "configure", "…"]
|
"capabilities": ["directories", "configure", "…"]
|
||||||
} }
|
} }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`limits` is `null` on `serve --stdio`. `connection` is the device's name for the instance — the
|
`limits` is `null` on `serve --stdio`. `limits.scheduled` (spec 3.5.0, capability `scheduled`): whether
|
||||||
|
this device accepts scheduled work now (`remote.scheduled`, *Scheduled work on a linked computer*). `connection` is the device's name for the instance — the
|
||||||
login's connection — or `null`; the `model` event and `announce` say directly whether a model is
|
login's connection — or `null`; the `model` event and `announce` say directly whether a model is
|
||||||
the instance's (`instance`).
|
the instance's (`instance`).
|
||||||
|
|
||||||
@@ -251,6 +253,10 @@ person may also answer at the terminal (a shared session), so the client does no
|
|||||||
can say which — a background subagent may ask while the session itself is idle. `subagent` (capability `tree`, spec 3.4.0):
|
can say which — a background subagent may ask while the session itself is idle. `subagent` (capability `tree`, spec 3.4.0):
|
||||||
`{ id, name, position }` when the call is a tree subagent's that nobody in the tree could allow (`subagents.md` §7) —
|
`{ id, name, position }` when the call is a tree subagent's that nobody in the tree could allow (`subagents.md` §7) —
|
||||||
the card names it; the answers are the same as for the main agent's own call.
|
the card names it; the answers are the same as for the main agent's own call.
|
||||||
|
`scheduled` (capability `scheduled`, spec 3.5.0): `{ schedule_id, run_id, wait_s }` when the call is
|
||||||
|
a scheduled turn's — nobody is at the chat, so the client puts the card to the person where they are
|
||||||
|
(LLeMbas: a notification on their phone carrying the card, answerable there and in every client),
|
||||||
|
and the device waits `wait_s` for it, not `remote.approval_timeout` (*Scheduled work*, below).
|
||||||
Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with
|
Answer: ACP's `{ outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }`, with
|
||||||
optional `_meta.lembas.command` (the corrected command line, `once` only) or
|
optional `_meta.lembas.command` (the corrected command line, `once` only) or
|
||||||
`_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial.
|
`_meta.lembas.feedback` (why it was denied). No answer within `remote.approval_timeout` is a denial.
|
||||||
@@ -306,6 +312,135 @@ is on the device, and gets it once the device links again. Over `/api/v2` the ch
|
|||||||
the tree as it changes and the endpoints to read a subagent's transcript and findings, the same for
|
the tree as it changes and the endpoints to read a subagent's transcript and findings, the same for
|
||||||
a chat's own subagents and for an agent chat's — so Web, Desktop and Android draw both alike.
|
a chat's own subagents and for an agent chat's — so Web, Desktop and Android draw both alike.
|
||||||
|
|
||||||
|
## Scheduled work on a linked computer (spec 3.5.0)
|
||||||
|
|
||||||
|
A schedule (the server's: its rule, its runs, its history and notifications) may run **on a linked
|
||||||
|
computer**: each run is an agent turn on that device, in a folder of it, instead of a chat on the
|
||||||
|
server. The server decides *when*; the device decides *whether*, and *how far* — every rule an agent
|
||||||
|
chat has still holds, and the ones below are added. Numbers and texts: `loop.json` `scheduled`.
|
||||||
|
Conformance areas `scheduled`, `silent`, `monitor`.
|
||||||
|
|
||||||
|
### The device's setting: Accept scheduled work
|
||||||
|
|
||||||
|
- **`remote.scheduled`** in the device's global config, **off by default**; shown as **Accept
|
||||||
|
scheduled work** (`loop.json` `scheduled.setting`). Set from the command line with
|
||||||
|
`lembas service scheduled on|off` (also `lembas remote scheduled on|off`; `lembas remote list
|
||||||
|
--json` says `scheduled`), and by LLeMbas Desktop through the same command. It is global only: a
|
||||||
|
project's config cannot turn it on.
|
||||||
|
- It narrows remote work and widens nothing: a scheduled turn also needs `remote.enabled`, a folder
|
||||||
|
inside `remote.roots`, a trusted project where `remote.require_trust`, and a mode within
|
||||||
|
`remote.max_mode`, exactly as an agent chat does.
|
||||||
|
- The device says it in `limits.scheduled` (hello and `initialize`). Turning it on or off changes
|
||||||
|
the limits, so the device dials again once nothing works (as for any limit) and the client hears
|
||||||
|
it. The client may offer the device in a schedule's form only while `limits.scheduled` is true,
|
||||||
|
and says why not otherwise. The device judges it again at every `session/new`, `session/prompt`
|
||||||
|
and `_lembas/monitor` that is scheduled, whatever it said earlier.
|
||||||
|
- **Off** (or remote work off): every scheduled request is refused with `-32602`, the message
|
||||||
|
`loop.json` `scheduled.texts.off` word for word, and `data: { reason: "scheduled_off" }`. Not
|
||||||
|
retried: it is the person's setting. A folder the device does not accept is `-32602` with the
|
||||||
|
usual reason and `data: { reason: "folder" }`.
|
||||||
|
|
||||||
|
### Starting a scheduled turn
|
||||||
|
|
||||||
|
The client opens an agent session as for an agent chat (`session/new`: `cwd`, `lembas_model`,
|
||||||
|
`effort`, `_meta.lembas` with `agents` — the schedule's **Min/Max**, as an agent chat's — and the
|
||||||
|
`Context`), marked scheduled:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"_meta": { "mode": "edit", "lembas_model": "qwen", "lembas": {
|
||||||
|
"agents": { "min": 1, "max": 4 },
|
||||||
|
"scheduled": { "schedule_id": "sched1", "run_id": "run7", "title": "Waybread check",
|
||||||
|
"mode": "edit", "approval_wait_s": 1800 } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
and runs the turn with `session/prompt`, its `_meta.lembas` carrying the same `scheduled` object
|
||||||
|
(and a `turnId`, the run's — so a prompt sent again after a dropped link gets the run's answer
|
||||||
|
instead of a second run). A schedule that continues one conversation (`continue`) prompts the same
|
||||||
|
session again on its next run; a fresh run opens a new one. The prompt's text is the server's own
|
||||||
|
for a scheduled run — the instruction, the run's time, and for a schedule that notifies only when
|
||||||
|
it matters the `[SILENT]` hint — composed exactly as for a run on the server; the device adds nothing.
|
||||||
|
|
||||||
|
**The mode** (`scheduled.mode`; `_meta.mode` is ignored for a scheduled session): `manual`, `edit`
|
||||||
|
or `auto`, and **`edit` when absent** — so a run is never wider than Edit unless the person picked
|
||||||
|
Auto on that schedule. `plan` is refused (`scheduled.texts.plan`, `data: { reason: "plan" }`):
|
||||||
|
nobody is there to approve a plan. Then lowered to `remote.max_mode`, as every remote session is —
|
||||||
|
Auto runs only where both the schedule and the device allow it (`conformance/scheduled.json`). The
|
||||||
|
mode holds for the turn; neither `_lembas/configure` nor a command in the prompt widens it.
|
||||||
|
|
||||||
|
In a scheduled turn:
|
||||||
|
|
||||||
|
- **Approvals** go to the person through the client (`session/request_permission` with
|
||||||
|
`_meta.lembas.scheduled: { schedule_id, run_id, wait_s }`), and the device waits
|
||||||
|
`approval_wait_s` — the schedule's wait, 30 minutes by default, clamped to
|
||||||
|
`scheduled.approval_wait_seconds` — instead of `remote.approval_timeout`. Unanswered by then, the
|
||||||
|
call is denied with `scheduled.texts.denied` as the feedback the model reads (`{{minutes}}` the
|
||||||
|
wait in whole minutes), the card is settled (`_lembas/permission/settled`), and the turn goes on —
|
||||||
|
the model works around the refusal or fails. Not `patient`.
|
||||||
|
- **`ask_user` and `plan_submit`** have nobody to answer: the device answers them as it does with
|
||||||
|
no client (`ask_user` tells the model nobody can be asked; `plan_submit` saves the plan and says
|
||||||
|
to stop). `show` is not offered: nobody is looking.
|
||||||
|
- Subagents as in an agent chat (`agents`, the tree, findings); a subagent's call only the person
|
||||||
|
can allow is an approval like the main agent's, with `scheduled` beside `subagent`.
|
||||||
|
|
||||||
|
### How the turn ends and reports
|
||||||
|
|
||||||
|
`session/prompt`'s answer carries the run's outcome in `_meta.lembas.scheduled`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "stopReason": "end_turn", "_meta": { "lembas": { "turnId": "run7",
|
||||||
|
"scheduled": { "run_id": "run7", "status": "ok", "text": "All the waybread is wrapped.",
|
||||||
|
"error": null } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`text`**: the turn's final reply — the text of the last assistant message the turn wrote, as
|
||||||
|
written (`[SILENT]` included). Model output: untrusted wherever it is shown.
|
||||||
|
- **`status`**: `cancelled` — the turn was cancelled (`session/cancel`: the person stopped it, or the
|
||||||
|
client's inactivity limit); `failed` — the turn ended on an error (`error` its text), or with no
|
||||||
|
reply text at all (`scheduled.texts.no_reply`); `silent` — the reply says the sentinel
|
||||||
|
`[SILENT]` on its first or last non-empty line (any case, a trailing full stop forgiven;
|
||||||
|
`conformance/silent.json`, the server's own rule for its runs); otherwise `ok`. A turn that hit the
|
||||||
|
step backstop or a budget and still wrote a reply is `ok`; `stopReason` says why it stopped.
|
||||||
|
- The client records the run from this (its history, its notification by the schedule's policy —
|
||||||
|
`silent` is never notified, a failure is told as a failure), and keeps the session as the run's
|
||||||
|
agent chat, whose events it received as for any agent chat. What the sentinel stripped looks like
|
||||||
|
is `silent.json`'s `stripped`.
|
||||||
|
|
||||||
|
### When the device is away, or refuses
|
||||||
|
|
||||||
|
- **Away** (not linked when the run is due, or the link drops before `session/new` is answered): the
|
||||||
|
client treats the device like a busy model — the run is `waiting` (`waiting_for: "device"`) and
|
||||||
|
tried again on the same steps a run waiting for a free model is, until the run's window closes;
|
||||||
|
then it is **missed**. A turn already running when the link drops is followed again by
|
||||||
|
`_lembas/session/status` and the turn id, as for any agent chat.
|
||||||
|
- **Refused** (`-32602` from a scheduled `session/new`, `session/prompt` or `_lembas/monitor`): the
|
||||||
|
run **fails** with the device's message, said in the run's history and in the failure
|
||||||
|
notification; `data.reason` (`scheduled_off`, `folder`, `plan`, `permission`) lets the client say
|
||||||
|
what to change. Not retried.
|
||||||
|
|
||||||
|
### Monitor mode: a command watched on the device
|
||||||
|
|
||||||
|
A schedule may **watch** something and run the model only when it changed. A URL is fetched by the
|
||||||
|
server and needs nothing from the device; a **command's output** is watched on the device with
|
||||||
|
`_lembas/monitor` (below): the client asks the device to run the watch command, compares the
|
||||||
|
answer's `hash` with the one the previous check stored, and when it is the same records the run as
|
||||||
|
`unchanged` — no model, nobody notified. Changed (or the first check), the model runs as the
|
||||||
|
schedule says — on the server, or as a scheduled turn on the device — told what changed through the
|
||||||
|
`excerpt`. A check that fails (timed out, refused) is a failed run.
|
||||||
|
|
||||||
|
The command runs under the **same permission rules as the schedule's mode**, with one difference:
|
||||||
|
**a command the mode would ask about is refused**, because a watch has nobody to ask. The device
|
||||||
|
judges the command line as `bash` would be judged in a session in that folder (the hardline, the
|
||||||
|
default rules, the global rules and — in a trusted project — the project's), and runs it only on
|
||||||
|
`allow`; `ask` is refused with `scheduled.texts.monitor_asks`, `deny` with
|
||||||
|
`scheduled.texts.monitor_denied`, both `-32602` with `data: { reason: "permission" }`
|
||||||
|
(`conformance/monitor.json`).
|
||||||
|
|
||||||
|
**The hash** (`monitor.json` `hash` cases): `sha256:` and the lowercase hex SHA-256 of the
|
||||||
|
output's bytes exactly as read — standard output and standard error as one stream, as the `bash`
|
||||||
|
tool reads them — followed by a newline and the exit status as decimal digits (after the bytes, so
|
||||||
|
the device hashes the output as it streams, however long). Nothing is normalised: a watch command
|
||||||
|
that prints a time changes every time, and should not.
|
||||||
|
|
||||||
## `_lembas/*` methods
|
## `_lembas/*` methods
|
||||||
|
|
||||||
Direction is who sends it. "→ result" marks a request; the others are notifications.
|
Direction is who sends it. "→ result" marks a request; the others are notifications.
|
||||||
@@ -313,7 +448,9 @@ Direction is who sends it. "→ result" marks a request; the others are notifica
|
|||||||
### `_lembas/hello` — agent → client
|
### `_lembas/hello` — agent → client
|
||||||
|
|
||||||
Sent once when the link opens, before `initialize`:
|
Sent once when the link opens, before `initialize`:
|
||||||
`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust } }`.
|
`{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust, scheduled } }`
|
||||||
|
(`scheduled` since spec 3.5.0: whether the device accepts scheduled work; a change of it is a
|
||||||
|
change of the limits, below).
|
||||||
|
|
||||||
A device links whether its remote work is on or not, and with no roots at all. Off, or with no
|
A device links whether its remote work is on or not, and with no roots at all. Off, or with no
|
||||||
roots, it says `limits.roots: []` here and in `initialize`, and refuses every `session/new`, every
|
roots, it says `limits.roots: []` here and in `initialize`, and refuses every `session/new`, every
|
||||||
@@ -350,6 +487,22 @@ client hears the new limits.
|
|||||||
| `phase` | `progress` — the progress object (`progress.md` §1), or `null` when nothing runs; `agent?` — the subagent's id when it is a subagent's, absent for the main agent's (capability `progress`). Sent when the phase changes, and for a change of its data at most four times a second. The existing `progress` event (the prompt's tokens read) is unchanged |
|
| `phase` | `progress` — the progress object (`progress.md` §1), or `null` when nothing runs; `agent?` — the subagent's id when it is a subagent's, absent for the main agent's (capability `progress`). Sent when the phase changes, and for a change of its data at most four times a second. The existing `progress` event (the prompt's tokens read) is unchanged |
|
||||||
| `team` | `tree` — the session's subagent tree (`subagents.md` §12, the data) — whenever it changes, at most four times a second (capability `tree`). A client draws it and fetches nothing else to do so |
|
| `team` | `tree` — the session's subagent tree (`subagents.md` §12, the data) — whenever it changes, at most four times a second (capability `tree`). A client draws it and fetches nothing else to do so |
|
||||||
|
|
||||||
|
### `_lembas/monitor` — client → agent → result (capability `scheduled`)
|
||||||
|
|
||||||
|
The watch command of a schedule in monitor mode (*Monitor mode*, above). Params
|
||||||
|
`{ cwd, command, mode?, timeout_s?, schedule_id?, run_id? }` — `cwd` absolute, judged as
|
||||||
|
`session/new`'s; `mode` the schedule's (`manual`, `edit` or `auto`; `edit` when absent; `plan`
|
||||||
|
refused), lowered to `remote.max_mode`; `timeout_s` defaulting to and clamped by
|
||||||
|
`loop.json` `scheduled.monitor.timeout_seconds`. Refused like any scheduled request when the device
|
||||||
|
does not accept scheduled work. The command runs as `bash -c` in `cwd`, in its own process group,
|
||||||
|
with no input; at the timeout the group is stopped.
|
||||||
|
|
||||||
|
Result `{ hash, exit, timed_out, excerpt, truncated, bytes, duration_ms }`: `hash` as above (`null`
|
||||||
|
when it timed out), `exit` the exit status (`null` on a signal or a timeout), `excerpt` the start of
|
||||||
|
the output decoded as UTF-8 (invalid bytes replaced), at most `scheduled.monitor.excerpt_chars`
|
||||||
|
characters, `truncated` whether there was more, `bytes` the output's length. The device hashes all
|
||||||
|
of the output as it streams and keeps at most `scheduled.monitor.output_max_bytes` of it.
|
||||||
|
|
||||||
### `_lembas/directories` — client → agent → result
|
### `_lembas/directories` — client → agent → result
|
||||||
|
|
||||||
Params `{ path?: string }`. Without a path: `{ path: "", places: Dir[] }`, the remote roots and every
|
Params `{ path?: string }`. Without a path: `{ path: "", places: Dir[] }`, the remote roots and every
|
||||||
@@ -682,6 +835,24 @@ Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`:
|
|||||||
older `library` scope counts as `memory`. Local memory files are not synced up; logged out, there
|
older `library` scope counts as `memory`. Local memory files are not synced up; logged out, there
|
||||||
is no memory.
|
is no memory.
|
||||||
|
|
||||||
|
- **Schedules** (spec 3.5.0): the schedule routes of `/api/v2` — `GET`/`POST /api/v2/schedules`,
|
||||||
|
`GET`/`PATCH`/`DELETE /api/v2/schedules/{id}`, `POST …/{id}/pause`, `/resume`, `/run`,
|
||||||
|
`GET …/{id}/runs`, `GET …/{id}/runs/{run}`, `POST /api/v2/schedules/compile` and `/preview` —
|
||||||
|
**accept the device's token** (scope `link`) as well as an app's (`app`), for the same person and
|
||||||
|
under the same `schedule.use` permission; nothing else of `/api/v2` does. The shapes are the
|
||||||
|
server's (its contract fixtures); what the CLI's `/schedule` relies on: a schedule's `id`,
|
||||||
|
`title`, `instruction`, `description`, `enabled`, `next_run`, `last_run` (`status`, `timing`,
|
||||||
|
`due_at`, `finished_at`, `preview`, `error.text`), `running`; a run's `id`, `status`, `due_at`,
|
||||||
|
`started_at`, `finished_at`, `model.name`, `preview`, `error`; compile's `ok`, `reason`, `title`,
|
||||||
|
`instruction`, `rule`, `description`, `next_runs`, `timezone`. A run's `status` may also be
|
||||||
|
**`unchanged`** (monitor mode: the watch saw nothing new; no model ran), and `waiting_for` may be
|
||||||
|
**`device`**. A schedule that runs on a linked computer has
|
||||||
|
**`runs_on: { device, device_name, cwd, mode, approval_wait_minutes }`** (else `null`); one that
|
||||||
|
watches has **`watch: { kind: "url", url } | { kind: "command", command, timeout_s }`** (else
|
||||||
|
`null`). Creating one with the device's token, `runs_on.device: "this"` names the calling device.
|
||||||
|
A server without these answers 401 or 403 to the device's token, and the CLI says the server is
|
||||||
|
older.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `_lembas/files/search`'s `root` is the session's working directory, which `@path` mentions
|
- `_lembas/files/search`'s `root` is the session's working directory, which `@path` mentions
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
{
|
||||||
|
"about": "Spec 3.5.0 (acp.md *Monitor mode*, `_lembas/monitor`): the watch command on a device. `hash`: `exit` and `output` (the bytes as UTF-8) → the answer's `hash`. `judge`: a command line in the schedule's mode, with the default rules (and any extra global rules given), the project root /project and nothing named on disk, as conformance/permission.json judges it → `run` (the mode allows it without asking), or `{refused}` with `asks` (the mode would ask: scheduled.texts.monitor_asks) or `denied` (scheduled.texts.monitor_denied). The device's own; the server keeps only the hash.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "Exit 0 and a line of output.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 0,
|
||||||
|
"output": "ok\n",
|
||||||
|
"expect": "sha256:9427ae13c91f98a3af2213b34fb59e4bb5aa9e6101a1f7407f526dde3571e6aa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The same output with another exit status is a change.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 1,
|
||||||
|
"output": "ok\n",
|
||||||
|
"expect": "sha256:21e0bbd37d04094ef29f22d0212bb0f4bedda1b1fab38d51f9b8670b5cf07df4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "No output.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 0,
|
||||||
|
"output": "",
|
||||||
|
"expect": "sha256:c7757c0896cbfe6182d8ea2bda4a8bf94addc428980eedab8609c57ca7ff1763"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A trailing newline counts: nothing is normalised.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 0,
|
||||||
|
"output": "ok",
|
||||||
|
"expect": "sha256:b9b8913ecae1ba8190ef1a22cc57afef08ad3933ab4b13a32f8cdccb07bb527b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "UTF-8 output, hashed as its bytes.",
|
||||||
|
"kind": "hash",
|
||||||
|
"exit": 0,
|
||||||
|
"output": "lembas · ✓\n",
|
||||||
|
"expect": "sha256:3d1c38d3b96ee10d71faac35eb2933ba13b68d6325872fdda3e459c9fbd17d98"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A read-only command in Edit runs.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "git status",
|
||||||
|
"mode": "edit",
|
||||||
|
"expect": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A command Edit would ask about is refused: a watch has nobody to ask.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "rm -rf build",
|
||||||
|
"mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"refused": "asks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "In Auto it runs.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "rm -rf build",
|
||||||
|
"mode": "auto",
|
||||||
|
"expect": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The hardline is a floor in every mode.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "rm -rf /",
|
||||||
|
"mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"refused": "denied"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A line that pipes a download into a shell asks in Manual: refused.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "curl -s https://example.org/feed | sh",
|
||||||
|
"mode": "manual",
|
||||||
|
"expect": {
|
||||||
|
"refused": "asks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A rule the person wrote allows it.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "make check",
|
||||||
|
"mode": "edit",
|
||||||
|
"rules": {
|
||||||
|
"bash": {
|
||||||
|
"make check": "allow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expect": "run"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A rule the person wrote denies it, even in Auto.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "make deploy",
|
||||||
|
"mode": "auto",
|
||||||
|
"rules": {
|
||||||
|
"bash": {
|
||||||
|
"make deploy": "deny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"refused": "denied"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading a secret asks: refused.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "cat .env",
|
||||||
|
"mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"refused": "asks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An ordinary listing runs in Manual.",
|
||||||
|
"kind": "judge",
|
||||||
|
"line": "ls -la",
|
||||||
|
"mode": "manual",
|
||||||
|
"expect": "run"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"about": "Spec 3.5.0 (acp.md *Scheduled work on a linked computer*): what a device makes of a scheduled turn. `accept`: the device's Accept scheduled work (remote.scheduled; false means remote work is off as well, the same refusal); `mode`: the schedule's (absent: none chosen); `max_mode`: remote.max_mode; `approval_wait_s`: the schedule's wait (absent: none). → `{mode, wait_s}` — the session's mode and how long an approval waits — or `{refused}` with the `data.reason` of the -32602 (`scheduled_off`, `plan`), whose message is loop.json scheduled.texts.off / .plan. Written from the rules; the device's other refusals (folder, trust) are the agent chat's.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "Off by default: refused, whatever else is asked.",
|
||||||
|
"accept": false,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"refused": "scheduled_off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "No mode chosen: Edit.",
|
||||||
|
"accept": true,
|
||||||
|
"max_mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "No mode chosen, the device allows Auto: still Edit — Auto only when the schedule says so.",
|
||||||
|
"accept": true,
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Auto chosen and allowed.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "auto",
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"mode": "auto",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Auto chosen, the device allows Edit at most: lowered.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "auto",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Edit chosen, the device allows Manual at most: lowered.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "manual",
|
||||||
|
"expect": {
|
||||||
|
"mode": "manual",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Manual chosen.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "manual",
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"mode": "manual",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Plan is refused: nobody is there to approve a plan.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "plan",
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"refused": "plan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An unknown mode is read as the default, never as anything wider.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "unrestricted",
|
||||||
|
"max_mode": "auto",
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The device allows Plan at most: every scheduled turn is lowered to it.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "plan",
|
||||||
|
"expect": {
|
||||||
|
"mode": "plan",
|
||||||
|
"wait_s": 1800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The wait, in seconds, as the schedule says.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"approval_wait_s": 600,
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 600
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Below the floor: one minute.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"approval_wait_s": 5,
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Above the ceiling: a day.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"approval_wait_s": 999999,
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 86400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A fraction is cut to whole seconds.",
|
||||||
|
"accept": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"max_mode": "edit",
|
||||||
|
"approval_wait_s": 90.7,
|
||||||
|
"expect": {
|
||||||
|
"mode": "edit",
|
||||||
|
"wait_s": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"about": "Spec 3.5.0: whether a scheduled run's reply is `silent` — loop.json scheduled.sentinel on its first or last non-empty line, compared after trimming, any case, every trailing full stop forgiven — and the reply with every sentinel line taken out (`stripped`, the text a preview shows), trimmed. The server's rule for its own runs, and the device's for a scheduled turn.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "The sentinel alone.",
|
||||||
|
"text": "[SILENT]",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Lower case, with a full stop.",
|
||||||
|
"text": "[silent].",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "On the last line, after a sentence.",
|
||||||
|
"text": "Nothing new since yesterday.\n[SILENT]",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": "Nothing new since yesterday."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "On the first line, before a sentence.",
|
||||||
|
"text": "[SILENT]\nChecked the feed: nothing.",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": "Checked the feed: nothing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "In the middle: not silent (only the first or last line counts), and the line still goes from the preview.",
|
||||||
|
"text": "Two new posts.\n[SILENT]\nBoth about waybread.",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": "Two new posts.\nBoth about waybread."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Inside a sentence: not silent.",
|
||||||
|
"text": "I would say [SILENT] but there is news: the build broke.",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": "I would say [SILENT] but there is news: the build broke."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Blank lines around it are not lines.",
|
||||||
|
"text": "\n\n [SILENT] \n\n",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A space before the full stop.",
|
||||||
|
"text": "All quiet.\n[SILENT] .",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": "All quiet."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Without brackets: not the sentinel.",
|
||||||
|
"text": "SILENT",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": "SILENT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Empty: not silent.",
|
||||||
|
"text": "",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An ordinary reply.",
|
||||||
|
"text": "The backup finished at 03:12; 4.2 GB.",
|
||||||
|
"expect": {
|
||||||
|
"silent": false,
|
||||||
|
"stripped": "The backup finished at 03:12; 4.2 GB."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Two full stops are forgiven too (every trailing one is).",
|
||||||
|
"text": "[SILENT]..",
|
||||||
|
"expect": {
|
||||||
|
"silent": true,
|
||||||
|
"stripped": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"about": "Teams (team.md, spec 3.2.0; the main agent and its tree, subagents.md §5–§13, spec 3.4.0 — a member's team_add is now the lead's with `parent`, and `offered` lists 3.4.0's tools): one lead session's team, step by step, written from a reference model of the rules. `start`: the size setting `min` and `max`, `subagents` (enabled for the session; default true) and the chat model's `capacity` (models/schema.json: `parallel`, `single_session`; {} — no limit). The lead is `lead`; its first prompt is running as the case begins, and every agent is on the chat's model, and it is the only model there is (spec 3.3.0: other models are subagents.json's). Steps and what each answers: `offered` (`by` an agent, default lead) — `{tools, setting}`: the team tools listed for that agent, sorted, and whether the setting is shown; `call` (`by`, `tool`, `args`) — `{result, delivered}`: the call's answer word for word (null where an ordinary tool would run: the case looks only at whether it is refused), and what was delivered to whom by this step, `{name: [message, …]}`; `team_read` answers `{room, delivered}` instead, `room` being the messages it shows as `{n, from, to}` (the tree and the text format are loop.json team.read's, checked by each project's own tests); `task` runs a subagent that counts toward the size (and holds a slot) until `task_done` (`by`) — answers null; `finish` (`by`, `reply`, `reason`: stop by default, or steps, budget, `error — <text>`) — the agent's turn ends: a member's reply is its report — `{delivered}`; `set` (`min`, `max`, `capacity`: the person's setting or the model's metadata changed) — null; `person_stop` (`name`) — the person stopped that member in the UI — null; `user` — the person's message to the lead — `{note}`: the note on it (context.md §10), null when there is none; `state` — the team in tree order as `{name, manager, state}`: working, waiting (for a slot), finished, idle (the lead between turns). Delivering to a finished member or the idle lead wakes it (it is then working, or waiting when its model is full). Spec 3.4.0 adds: `start.mode` — the main agent's mode (default edit); `call` to team_whoami, team_finish, team_findings, team_request, team_decide, team_assign, team_replace, answered word for word; `context` (`by`, `used`, `window`) — its engine's context use — null; `context_full` (`by`) — its context reached team.context.pause_at: `{delivered}`; `started` (`by`) — `{text}`: what its latest turn was started with (a brief, or the messages that woke it); `tree` — `{size, open_slots, agents: [{id, name, parent, state, mode, request}], ended: [{id, name, state}]}`; `main_mode` (`mode`) — the main agent's mode changed — null. LLeMbas: services/team.py; CLI: team/.",
|
"about": "Teams (team.md, spec 3.2.0; the main agent and its tree, subagents.md §5–§13, spec 3.4.0 — a member's team_add is now the lead's with `parent`, and `offered` lists 3.4.0's tools): one lead session's team, step by step, written from a reference model of the rules. `start`: the size setting `min` and `max`, `subagents` (enabled for the session; default true) and the chat model's `capacity` (models/schema.json: `parallel`, `single_session`; {} — no limit). The lead is `lead`; its first prompt is running as the case begins, and every agent is on the chat's model, and it is the only model there is (spec 3.3.0: other models are subagents.json's). Spec 3.5.0: a subagent added under a sub-manager is said to report to it (`added_under`). Steps and what each answers: `offered` (`by` an agent, default lead) — `{tools, setting}`: the team tools listed for that agent, sorted, and whether the setting is shown; `call` (`by`, `tool`, `args`) — `{result, delivered}`: the call's answer word for word (null where an ordinary tool would run: the case looks only at whether it is refused), and what was delivered to whom by this step, `{name: [message, …]}`; `team_read` answers `{room, delivered}` instead, `room` being the messages it shows as `{n, from, to}` (the tree and the text format are loop.json team.read's, checked by each project's own tests); `task` runs a subagent that counts toward the size (and holds a slot) until `task_done` (`by`) — answers null; `finish` (`by`, `reply`, `reason`: stop by default, or steps, budget, `error — <text>`) — the agent's turn ends: a member's reply is its report — `{delivered}`; `set` (`min`, `max`, `capacity`: the person's setting or the model's metadata changed) — null; `person_stop` (`name`) — the person stopped that member in the UI — null; `user` — the person's message to the lead — `{note}`: the note on it (context.md §10), null when there is none; `state` — the team in tree order as `{name, manager, state}`: working, waiting (for a slot), finished, idle (the lead between turns). Delivering to a finished member or the idle lead wakes it (it is then working, or waiting when its model is full). Spec 3.4.0 adds: `start.mode` — the main agent's mode (default edit); `call` to team_whoami, team_finish, team_findings, team_request, team_decide, team_assign, team_replace, answered word for word; `context` (`by`, `used`, `window`) — its engine's context use — null; `context_full` (`by`) — its context reached team.context.pause_at: `{delivered}`; `started` (`by`) — `{text}`: what its latest turn was started with (a brief, or the messages that woke it); `tree` — `{size, open_slots, agents: [{id, name, parent, state, mode, request}], ended: [{id, name, state}]}`; `main_mode` (`mode`) — the main agent's mode changed — null. LLeMbas: services/team.py; CLI: team/.",
|
||||||
"cases": [
|
"cases": [
|
||||||
{
|
{
|
||||||
"about": "Offered on a model that serves two requests at once: the lead has all four tools and the setting is shown.",
|
"about": "Offered on a model that serves two requests at once: the lead has all four tools and the setting is shown.",
|
||||||
@@ -717,7 +717,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added db (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 4 agents.",
|
"result": "Added db (owns a part) under backend: it is working. Its report goes to backend when it finishes. The team is 3 of 4 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1637,7 +1637,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1878,7 +1878,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2155,11 +2155,11 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 8 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 8 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added db (owns a part): it is working. Its report comes to you when it finishes. The team is 4 of 8 agents.",
|
"result": "Added db (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 4 of 8 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2464,7 +2464,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 5 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 5 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2836,15 +2836,15 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 8 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 8 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added auth (owns a part): it is working. Its report comes to you when it finishes. The team is 4 of 8 agents.",
|
"result": "Added auth (owns a part) under routes: it is working. Its report goes to routes when it finishes. The team is 4 of 8 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added db (owns a part): it is working. Its report comes to you when it finishes. The team is 5 of 8 agents.",
|
"result": "Added db (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 5 of 8 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2890,7 +2890,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
"result": "Added auth (owns a part): it is working. Its report comes to you when it finishes. The team is 5 of 8 agents.",
|
"result": "Added auth (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 5 of 8 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2968,7 +2968,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3647,7 +3647,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3978,7 +3978,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4382,7 +4382,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4843,7 +4843,7 @@
|
|||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"result": "Added routes (owns a part): it is working. Its report comes to you when it finishes. The team is 3 of 6 agents.",
|
"result": "Added routes (owns a part) under api: it is working. Its report goes to api when it finishes. The team is 3 of 6 agents.",
|
||||||
"delivered": {}
|
"delivered": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -146,6 +146,8 @@
|
|||||||
"texts": {
|
"texts": {
|
||||||
"added": "Added {{name}} ({{role}}): it is working. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.",
|
"added": "Added {{name}} ({{role}}): it is working. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.",
|
||||||
"added_waiting": "Added {{name}} ({{role}}): it waits for a free slot — the model serves {{parallel}} requests at once and all are busy — and starts when one frees. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.",
|
"added_waiting": "Added {{name}} ({{role}}): it waits for a free slot — the model serves {{parallel}} requests at once and all are busy — and starts when one frees. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.",
|
||||||
|
"added_under": "Added {{name}} ({{role}}) under {{parent}}: it is working. Its report goes to {{parent}} when it finishes. The team is {{size}} of {{max}} agents.",
|
||||||
|
"added_waiting_under": "Added {{name}} ({{role}}) under {{parent}}: it waits for a free slot — the model serves {{parallel}} requests at once and all are busy — and starts when one frees. Its report goes to {{parent}} when it finishes. The team is {{size}} of {{max}} agents.",
|
||||||
"sent": "Sent to {{to}}.",
|
"sent": "Sent to {{to}}.",
|
||||||
"stopped": "Stopped {{names}}. The team is {{size}} of {{max}} agents.",
|
"stopped": "Stopped {{names}}. The team is {{size}} of {{max}} agents.",
|
||||||
"manager": "You lead a team: give this to a member (team_add or team_send).",
|
"manager": "You lead a team: give this to a member (team_add or team_send).",
|
||||||
@@ -438,5 +440,48 @@
|
|||||||
],
|
],
|
||||||
"what_max": 80,
|
"what_max": 80,
|
||||||
"pace_per_second": 4
|
"pace_per_second": 4
|
||||||
|
},
|
||||||
|
"scheduled": {
|
||||||
|
"about": "Spec 3.5.0 (acp.md *Scheduled work on a linked computer*): a schedule's run as an agent turn on a linked computer, and the watch command of monitor mode. `setting` is how the device's switch is shown; `modes` the modes a schedule may choose (`default_mode` when it chose none), lowered to remote.max_mode; `approval_wait_seconds` how long an approval in a scheduled turn waits (the schedule's wait, clamped); `sentinel` the word a reply says when there is nothing to tell; `monitor` the watch command's limits. conformance/scheduled.json, silent.json, monitor.json.",
|
||||||
|
"setting": {
|
||||||
|
"key": "remote.scheduled",
|
||||||
|
"label": "Accept scheduled work",
|
||||||
|
"hint": "Let your schedules run here as agent turns, in the folders remote work may use. Off by default."
|
||||||
|
},
|
||||||
|
"modes": [
|
||||||
|
"manual",
|
||||||
|
"edit",
|
||||||
|
"auto"
|
||||||
|
],
|
||||||
|
"default_mode": "edit",
|
||||||
|
"approval_wait_seconds": {
|
||||||
|
"default": 1800,
|
||||||
|
"min": 60,
|
||||||
|
"max": 86400
|
||||||
|
},
|
||||||
|
"sentinel": "[SILENT]",
|
||||||
|
"statuses": [
|
||||||
|
"ok",
|
||||||
|
"silent",
|
||||||
|
"failed",
|
||||||
|
"cancelled"
|
||||||
|
],
|
||||||
|
"monitor": {
|
||||||
|
"timeout_seconds": {
|
||||||
|
"default": 60,
|
||||||
|
"min": 1,
|
||||||
|
"max": 600
|
||||||
|
},
|
||||||
|
"excerpt_chars": 2000,
|
||||||
|
"output_max_bytes": 1048576
|
||||||
|
},
|
||||||
|
"texts": {
|
||||||
|
"off": "This computer does not accept scheduled work. Turn on \"Accept scheduled work\" on it (lembas service scheduled on).",
|
||||||
|
"plan": "A scheduled run cannot use plan mode: nobody is there to approve a plan. Choose Manual, Edit or Auto.",
|
||||||
|
"denied": "Nobody answered this approval within {{minutes}} minutes, so it counts as denied.",
|
||||||
|
"monitor_asks": "The watch command would need approval in {{mode}} mode ({{reason}}), and a watch has nobody to ask. Allow the command in the project's permission rules, or choose a wider mode for the schedule.",
|
||||||
|
"monitor_denied": "The watch command is refused here: {{reason}}.",
|
||||||
|
"no_reply": "The run ended without a reply."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,10 @@
|
|||||||
"description": "false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true.",
|
"description": "false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"notify": {
|
||||||
|
"description": "When a reply finishes, or the agent waits for you, while the terminal is not focused: ring the bell and ask the terminal for a desktop notification (OSC 9) naming the session — never what the reply says. Default true; false: neither.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"description": "emoji (default), or plain: one-column symbols for a terminal font that has no emoji.",
|
"description": "emoji (default), or plain: one-column symbols for a terminal font that has no emoji.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1075,6 +1079,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.",
|
"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"
|
"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": {
|
"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.",
|
"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"
|
"type": "boolean"
|
||||||
|
|||||||
+101
-10
@@ -44,6 +44,7 @@ import type { Hub } from "./hub.ts"
|
|||||||
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.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 { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
|
||||||
import { isNote } from "../session/prefix.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
|
export const ACP_VERSION = 1
|
||||||
/** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
|
/** 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:
|
/** `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. */
|
* nothing new is opened or shared, whatever the roots say. Absent: not judged here. */
|
||||||
enabled?: boolean
|
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 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. */
|
* the sessions shared from that terminal can be reached; the web UI opens none of its own. */
|
||||||
sharedOnly?: boolean
|
sharedOnly?: boolean
|
||||||
@@ -180,6 +183,9 @@ export interface Session {
|
|||||||
turns: TurnLog<PromptResult>
|
turns: TurnLog<PromptResult>
|
||||||
/** The command set last sent to the client, to send it again only when it changed. */
|
/** The command set last sent to the client, to send it again only when it changed. */
|
||||||
commands?: string
|
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. */
|
/** What `session/prompt` answers. */
|
||||||
@@ -279,6 +285,10 @@ export const CAPABILITIES = [
|
|||||||
"progress",
|
"progress",
|
||||||
"tree",
|
"tree",
|
||||||
"agents",
|
"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
|
] as const
|
||||||
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
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
|
/** `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
|
* (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. */
|
* 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
|
const offerAlways = !req.request.alwaysAsk
|
||||||
return {
|
return {
|
||||||
sessionId,
|
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 } } : {}),
|
...(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.
|
// 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 } : {}),
|
...(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/transcript", (p) => this.tree("team_transcript", p))
|
||||||
peer.handle("_lembas/team/findings", (p) => this.tree("team_findings", 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/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/input", (p) => this.terminalInput(p))
|
||||||
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
|
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
|
||||||
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
||||||
@@ -528,7 +542,7 @@ export class AcpAgent {
|
|||||||
lembas: {
|
lembas: {
|
||||||
protocol: this.protocol,
|
protocol: this.protocol,
|
||||||
harness_spec: HARNESS_VERSION,
|
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
|
// 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.
|
// instance's own model `<name>`; any other ref is one only this device has.
|
||||||
connection: this.instanceConnection ?? null,
|
connection: this.instanceConnection ?? null,
|
||||||
@@ -587,12 +601,15 @@ export class AcpAgent {
|
|||||||
const cwd = typeof p?.cwd === "string" ? expandHome(p.cwd) : ""
|
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 (!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`)
|
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)
|
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 L = this.limits
|
||||||
const wanted = asMode(p?._meta?.mode) ?? undefined
|
const wanted = asMode(p?._meta?.mode) ?? undefined
|
||||||
const mode = L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
|
// A scheduled session runs in the schedule's mode, never `_meta.mode`.
|
||||||
const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog() }
|
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
|
// 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.
|
// 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
|
// 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. */
|
* `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */
|
||||||
private equipShow(s: Session) {
|
private equipShow(s: Session) {
|
||||||
const ctx = s.app.engine.o.toolCtx
|
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) => {
|
ctx.show = async (req, callId) => {
|
||||||
if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" }
|
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
|
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,
|
// A turn id this session has taken already: not run again — the original's answer,
|
||||||
// now or when it is done.
|
// now or when it is done.
|
||||||
if (s.turns.has(turnId)) return s.turns.run(turnId, () => Promise.reject(new Error("unreachable")))
|
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)
|
this.takeContext(s, p)
|
||||||
const prepared = preparePrompt(s.app, s.id, p.prompt, { roots: this.limits?.roots })
|
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
|
// 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.
|
// 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> => {
|
const run = async (started: () => void): Promise<PromptResult> => {
|
||||||
s.prompting = true
|
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()
|
started()
|
||||||
try {
|
try {
|
||||||
return await this.runPrepared(s, prepared, turnId)
|
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. */
|
* 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> {
|
private async runPrepared(s: Session, prepared: ReturnType<typeof preparePrompt>, turnId: string): Promise<PromptResult> {
|
||||||
const app = s.app
|
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 } } })
|
const done = (meta: Record<string, unknown>): PromptResult => ({ stopReason: "end_turn", _meta: { lembas: { turnId, ...meta } } })
|
||||||
if (prepared.kind === "message") {
|
if (prepared.kind === "message") {
|
||||||
app.bus.emit({ type: "notice", message: `/${prepared.name}: ${prepared.text}` })
|
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
|
/** `_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
|
* after a restart or a dropped link, so the instance follows a turn still running instead of
|
||||||
* sending its prompt a second time. */
|
* 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`. */
|
* (its time ran out, the link dropped) is taken down with `_lembas/permission/settled`. */
|
||||||
private asker(s: Session): Asker {
|
private asker(s: Session): Asker {
|
||||||
const agentOf = () => s.agent ?? this
|
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). */
|
/** 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) => {
|
const unknown = (a: AcpAgent, e: unknown) => {
|
||||||
if (!(e instanceof RpcError) || e.code !== METHOD_NOT_FOUND) return false
|
if (!(e instanceof RpcError) || e.code !== METHOD_NOT_FOUND) return false
|
||||||
@@ -1382,15 +1471,17 @@ export class AcpAgent {
|
|||||||
ask: async (req): Promise<AskReply> => {
|
ask: async (req): Promise<AskReply> => {
|
||||||
// Whichever link holds the session now; none (dropped, not back yet) is nobody to ask.
|
// Whichever link holds the session now; none (dropped, not back yet) is nobody to ask.
|
||||||
const agent = agentOf()
|
const agent = agentOf()
|
||||||
const timeout = agent.limits?.approvalTimeoutMs ?? 0
|
// A scheduled turn's approval waits the schedule's wait (spec 3.5.0), and says so.
|
||||||
const params = permissionParams(s.id, req)
|
const turn = s.scheduled
|
||||||
|
const timeout = turn ? turn.waitMs : (agent.limits?.approvalTimeoutMs ?? 0)
|
||||||
|
const params = permissionParams(s.id, req, turn ? { scheduled: turn } : {})
|
||||||
try {
|
try {
|
||||||
if (agent.peer.isClosed) throw new Error("not linked")
|
if (agent.peer.isClosed) throw new Error("not linked")
|
||||||
const answer = await agent.peer.request("session/request_permission", params, timeout || undefined)
|
const answer = await agent.peer.request("session/request_permission", params, timeout || undefined)
|
||||||
return replyFrom(answer, !req.request.alwaysAsk)
|
return replyFrom(answer, !req.request.alwaysAsk)
|
||||||
} catch {
|
} catch {
|
||||||
settle(agent, params.toolCall.toolCallId)
|
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
|
// Getters: whether there is anybody to ask is decided when the tool runs, by the link
|
||||||
|
|||||||
+5
-2
@@ -56,6 +56,9 @@ export function limitsFromConfig(o: { sharedOnly?: boolean } = {}): Limits & { e
|
|||||||
get terminal() {
|
get terminal() {
|
||||||
return r().terminal === true
|
return r().terminal === true
|
||||||
},
|
},
|
||||||
|
get scheduled() {
|
||||||
|
return r().enabled === true && r().scheduled === true
|
||||||
|
},
|
||||||
get terminalIntegration() {
|
get terminalIntegration() {
|
||||||
return r().terminal_integration !== false
|
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
|
/** 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. */
|
* capabilities): when it changes, the link is dialled again so the instance hears the new one. */
|
||||||
function announced(L: Limits & { enabled: boolean }): string {
|
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 {
|
export function pickInstance(name?: string): Instance {
|
||||||
@@ -199,7 +202,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
harness_spec: HARNESS_VERSION,
|
harness_spec: HARNESS_VERSION,
|
||||||
device: { host: hostname(), platform: `${process.platform}-${process.arch}` },
|
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")
|
status("linked")
|
||||||
// The limits apply at once, each time they are looked at. What the instance was told at
|
// The limits apply at once, each time they are looked at. What the instance was told at
|
||||||
|
|||||||
@@ -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
@@ -45,12 +45,16 @@ Usage:
|
|||||||
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
||||||
lembas service install | uninstall | status | logs [-f] | run
|
lembas service install | uninstall | status | logs [-f] | run
|
||||||
work from a LLeMbas instance as a background service (remote:)
|
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
|
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
|
what the instance may do here (remote:): the directories it may
|
||||||
work in (add turns remote work on), the most permissive mode,
|
work in (add turns remote work on), the most permissive mode,
|
||||||
terminals; a running service applies a change within seconds
|
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 [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 auth <server> sign in to a remote MCP server (OAuth)
|
||||||
lembas mcp logout <server> forget a server's stored sign-in
|
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 === "status") return void console.log(service.status().join("\n"))
|
||||||
if (sub === "logs") process.exit(service.logs(argv.includes("-f") || argv.includes("--follow")))
|
if (sub === "logs") process.exit(service.logs(argv.includes("-f") || argv.includes("--follow")))
|
||||||
if (sub === "run") process.exit(await service.run(argv[2]))
|
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") {
|
if (cmd === "run") {
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface RemoteState {
|
|||||||
roots: string[]
|
roots: string[]
|
||||||
max_mode: Mode
|
max_mode: Mode
|
||||||
terminal: boolean
|
terminal: boolean
|
||||||
|
/** Accept scheduled work (spec 3.5.0). */
|
||||||
|
scheduled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remoteState(b: RemoteBlock = remoteBlock()): RemoteState {
|
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))),
|
roots: (b.roots ?? []).map((r) => resolve(expandHome(r))),
|
||||||
max_mode: b.max_mode ?? "edit",
|
max_mode: b.max_mode ?? "edit",
|
||||||
terminal: b.terminal === true,
|
terminal: b.terminal === true,
|
||||||
|
scheduled: b.scheduled === true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export const Config = z.strictObject({
|
|||||||
skin: z.string().optional().describe("The old name of `theme`; still read when `theme` is not set."),
|
skin: z.string().optional().describe("The old name of `theme`; still read when `theme` is not set."),
|
||||||
busy_input: z.enum(["steer", "queue"]).optional().describe("A message sent while the agent works: steer (default) — it goes in at the next step, after the running tools finish, and the agent carries on with it; queue — it is sent as the next prompt once the task is done."),
|
busy_input: z.enum(["steer", "queue"]).optional().describe("A message sent while the agent works: steer (default) — it goes in at the next step, after the running tools finish, and the agent carries on with it; queue — it is sent as the next prompt once the task is done."),
|
||||||
mouse: z.boolean().optional().describe("false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true."),
|
mouse: z.boolean().optional().describe("false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true."),
|
||||||
|
notify: z.boolean().optional().describe("When a reply finishes, or the agent waits for you, while the terminal is not focused: ring the bell and ask the terminal for a desktop notification (OSC 9) naming the session — never what the reply says. Default true; false: neither."),
|
||||||
icons: z.enum(["emoji", "plain"]).optional().describe("emoji (default), or plain: one-column symbols for a terminal font that has no emoji."),
|
icons: z.enum(["emoji", "plain"]).optional().describe("emoji (default), or plain: one-column symbols for a terminal font that has no emoji."),
|
||||||
personality: z
|
personality: z
|
||||||
.enum(PERSONALITIES)
|
.enum(PERSONALITIES)
|
||||||
@@ -317,6 +318,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)."),
|
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)."),
|
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."),
|
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."),
|
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()
|
.optional()
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const SETTINGS: SettingDef[] = [
|
|||||||
{ key: "theme_background", kind: "enum", values: ["theme", "terminal"], agent: true, scopes: USER, fallback: "theme" },
|
{ key: "theme_background", kind: "enum", values: ["theme", "terminal"], agent: true, scopes: USER, fallback: "theme" },
|
||||||
{ key: "busy_input", kind: "enum", values: ["steer", "queue"], agent: true, scopes: USER, fallback: "steer" },
|
{ key: "busy_input", kind: "enum", values: ["steer", "queue"], agent: true, scopes: USER, fallback: "steer" },
|
||||||
{ key: "icons", kind: "enum", values: ["emoji", "plain"], agent: true, scopes: USER, fallback: "emoji" },
|
{ key: "icons", kind: "enum", values: ["emoji", "plain"], agent: true, scopes: USER, fallback: "emoji" },
|
||||||
|
{ key: "notify", kind: "boolean", agent: true, scopes: USER, fallback: "true", hint: "bell and desktop notification when a reply finishes or the agent waits for you while the terminal is not focused" },
|
||||||
{ key: "mouse", kind: "boolean", agent: true, scopes: ["global"], reload: true, fallback: "true" },
|
{ key: "mouse", kind: "boolean", agent: true, scopes: ["global"], reload: true, fallback: "true" },
|
||||||
{ key: "compaction.auto_at", kind: "number", agent: true, scopes: ALL, fallback: "0.85" },
|
{ key: "compaction.auto_at", kind: "number", agent: true, scopes: ALL, fallback: "0.85" },
|
||||||
{ key: "search.order", kind: "list", values: ["searxng", "firecrawl", "ddg"], agent: true, scopes: ALL, fallback: "those configured, then ddg" },
|
{ key: "search.order", kind: "list", values: ["searxng", "firecrawl", "ddg"], agent: true, scopes: ALL, fallback: "those configured, then ddg" },
|
||||||
|
|||||||
+13
-1
@@ -142,7 +142,7 @@ export const LOOP: {
|
|||||||
work: { write: string[]; execute: string[]; kept: string[] }
|
work: { write: string[]; execute: string[]; kept: string[] }
|
||||||
read_max: number
|
read_max: number
|
||||||
texts: Record<
|
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).
|
// 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",
|
| "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
|
string
|
||||||
@@ -171,6 +171,18 @@ export const LOOP: {
|
|||||||
whoami: { lines: Record<"who" | "parent" | "task" | "mode" | "request" | "model" | "limits" | "spawn_yes" | "spawn_no" | "context", string> }
|
whoami: { lines: Record<"who" | "parent" | "task" | "mode" | "request" | "model" | "limits" | "spawn_yes" | "spawn_no" | "context", string> }
|
||||||
findings: { header: string; item: 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 (spec 3.4.0, harness/progress.md): the phases, their texts, the line. */
|
||||||
progress: {
|
progress: {
|
||||||
phases: string[]
|
phases: string[]
|
||||||
|
|||||||
@@ -107,6 +107,16 @@ async function call(url: string, init: RequestInit, tls: Tls | undefined, timeou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One request to the instance with the device's token, its JSON answer and status (spec 3.5.0:
|
||||||
|
* the schedule routes of /api/v2). A body that is not JSON is null. */
|
||||||
|
export async function apiCall(base: string, token: string, tls: Tls | undefined, method: string, path: string, body?: unknown, timeoutMs = 30_000): Promise<{ status: number; json: any }> {
|
||||||
|
const headers: Record<string, string> = { authorization: `Bearer ${token}`, accept: "application/json" }
|
||||||
|
if (body !== undefined) headers["content-type"] = "application/json"
|
||||||
|
const res = await call(`${base}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }, tls, timeoutMs)
|
||||||
|
const json = res.status === 204 ? null : await res.json().catch(() => null)
|
||||||
|
return { status: res.status, json }
|
||||||
|
}
|
||||||
|
|
||||||
/** What a person typed, as a base address: a scheme added (https unless they said http), the path
|
/** What a person typed, as a base address: a scheme added (https unless they said http), the path
|
||||||
* and a trailing slash dropped. */
|
* and a trailing slash dropped. */
|
||||||
export function normalise(address: string): string {
|
export function normalise(address: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// `/schedule`: the account's schedules on the LLeMbas server, over the schedule routes of /api/v2,
|
||||||
|
// which accept this device's token (harness acp.md *The HTTP API*, spec 3.5.0). Scheduling is the
|
||||||
|
// server's — the rule, the runs, the history, the notifications; this is a window onto it from the
|
||||||
|
// terminal: list, create from words (the server's compile), pause, resume, run now, history.
|
||||||
|
import { remoteState } from "../config/remote.ts"
|
||||||
|
import { apiCall, LembasError, SignInRejected } from "./client.ts"
|
||||||
|
import { soleInstance } from "./personal.ts"
|
||||||
|
|
||||||
|
export interface Run {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
timing?: string
|
||||||
|
due_at?: string | null
|
||||||
|
started_at?: string | null
|
||||||
|
finished_at?: string | null
|
||||||
|
waiting_for?: string | null
|
||||||
|
retry_at?: string | null
|
||||||
|
model?: { name?: string | null } | null
|
||||||
|
preview?: string
|
||||||
|
error?: { class?: string; text?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Schedule {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
instruction: string
|
||||||
|
description?: string | null
|
||||||
|
enabled: boolean
|
||||||
|
next_run?: string | null
|
||||||
|
last_run?: Run | null
|
||||||
|
running?: boolean
|
||||||
|
runs_on?: { device?: string; device_name?: string; cwd?: string; mode?: string } | null
|
||||||
|
watch?: { kind: string; url?: string; command?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Compiled {
|
||||||
|
ok: boolean
|
||||||
|
reason?: string | null
|
||||||
|
title: string
|
||||||
|
instruction: string
|
||||||
|
target?: string
|
||||||
|
rule: Record<string, unknown>
|
||||||
|
description?: string | null
|
||||||
|
next_runs: string[]
|
||||||
|
timezone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One call to the schedule routes; the server's own refusal (`detail`) as the error. */
|
||||||
|
async function api(method: string, path: string, body?: unknown): Promise<any> {
|
||||||
|
const one = soleInstance()
|
||||||
|
if (!one) throw new LembasError("not logged in to one LLeMbas server (/login)")
|
||||||
|
const r = await apiCall(one.instance.base_url, one.token, one.instance.ca ? { ca: one.instance.ca } : undefined, method, `/api/v2${path}`, body)
|
||||||
|
const detail = typeof r.json?.detail === "string" ? r.json.detail : undefined
|
||||||
|
if (r.status === 401 && !detail) throw new SignInRejected("the server no longer accepts this sign-in — /login again")
|
||||||
|
// A server from before spec 3.5.0 lets only an app's token reach /api/v2.
|
||||||
|
if (r.status === 401 || (r.status === 403 && /scope|token/i.test(detail ?? "")))
|
||||||
|
throw new LembasError(`${one.instance.connection} does not let a linked computer manage schedules yet (it needs a newer LLeMbas server)`)
|
||||||
|
if (r.status === 404 && !detail) throw new LembasError(`${one.instance.connection} has no schedules here (an older LLeMbas server)`)
|
||||||
|
if (r.status >= 400) throw new LembasError(detail ?? `${method} ${path}: ${r.status}`)
|
||||||
|
return r.json
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listSchedules = async (): Promise<Schedule[]> => ((await api("GET", "/schedules"))?.schedules ?? []) as Schedule[]
|
||||||
|
export const compileWords = async (text: string): Promise<Compiled> => (await api("POST", "/schedules/compile", { text })) as Compiled
|
||||||
|
export const pauseSchedule = async (id: string): Promise<Schedule> => (await api("POST", `/schedules/${encodeURIComponent(id)}/pause`)).schedule
|
||||||
|
export const resumeSchedule = async (id: string): Promise<Schedule> => (await api("POST", `/schedules/${encodeURIComponent(id)}/resume`)).schedule
|
||||||
|
export const runSchedule = async (id: string): Promise<Run> => (await api("POST", `/schedules/${encodeURIComponent(id)}/run`)).run
|
||||||
|
export const scheduleRuns = async (id: string, limit = 10): Promise<{ runs: Run[]; more: boolean }> => {
|
||||||
|
const r = await api("GET", `/schedules/${encodeURIComponent(id)}/runs?limit=${limit}`)
|
||||||
|
return { runs: (r?.runs ?? []) as Run[], more: r?.more === true }
|
||||||
|
}
|
||||||
|
export const runResult = async (id: string, run: string): Promise<{ run: Run; text: string | null }> => api("GET", `/schedules/${encodeURIComponent(id)}/runs/${encodeURIComponent(run)}`)
|
||||||
|
|
||||||
|
/** Create one from what compile said. `here`: it runs on this computer, in `cwd`, in Edit
|
||||||
|
* (`runs_on.device: "this"` is the calling device). */
|
||||||
|
export async function createSchedule(c: Compiled, request: string, here?: { cwd: string }): Promise<Schedule> {
|
||||||
|
const body: Record<string, unknown> = { title: c.title, instruction: c.instruction, request, rule: c.rule, ...(c.target ? { target: c.target } : {}) }
|
||||||
|
if (here) body.runs_on = { device: "this", cwd: here.cwd, mode: "edit" }
|
||||||
|
return (await api("POST", "/schedules", body)).schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A schedule by its id, or by a title that starts with `query` (no case) when only one does. */
|
||||||
|
export function findSchedule(all: Schedule[], query: string): Schedule | string {
|
||||||
|
const q = query.trim()
|
||||||
|
if (!q) return "name a schedule: its id or the start of its title (/schedule lists them)"
|
||||||
|
const exact = all.find((s) => s.id === q)
|
||||||
|
if (exact) return exact
|
||||||
|
const low = q.toLowerCase()
|
||||||
|
const titled = all.filter((s) => s.title.toLowerCase() === low)
|
||||||
|
if (titled.length === 1) return titled[0]!
|
||||||
|
const starts = all.filter((s) => s.title.toLowerCase().startsWith(low))
|
||||||
|
if (starts.length === 1) return starts[0]!
|
||||||
|
if (!starts.length) return `no schedule "${q}" — /schedule lists them`
|
||||||
|
return `"${q}" fits ${starts.length} schedules: ${starts.map((s) => `${s.title} (${s.id})`).join(", ")} — use the id`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A time as the person reads it: the local date and time, minutes only. */
|
||||||
|
export function when(iso: string | null | undefined, now = new Date()): string {
|
||||||
|
if (!iso) return "—"
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0")
|
||||||
|
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
const sameDay = d.toDateString() === now.toDateString()
|
||||||
|
return sameDay ? `today ${time}` : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A run's status in a word or two: what history and the list show. */
|
||||||
|
export function runStatus(r: Run): string {
|
||||||
|
if (r.status === "waiting") return r.waiting_for === "device" ? "waiting for the computer" : r.waiting_for === "model" ? "waiting for a free model" : "waiting to try again"
|
||||||
|
if (r.status === "missed") return "missed"
|
||||||
|
if (r.status === "unchanged") return "unchanged (nothing new)"
|
||||||
|
if (r.status === "silent") return "nothing to report"
|
||||||
|
return r.status
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/schedule` and `/schedule list`. */
|
||||||
|
export function listLines(all: Schedule[], now = new Date()): string[] {
|
||||||
|
if (!all.length) return ["no schedules yet — /schedule new <what, and when> (\"every weekday at 8 summarise the open issues\")"]
|
||||||
|
const out: string[] = []
|
||||||
|
for (const s of all) {
|
||||||
|
const state = s.running ? "running" : s.enabled ? `next ${when(s.next_run, now)}` : "paused"
|
||||||
|
const last = s.last_run ? ` · last ${runStatus(s.last_run)}${s.last_run.finished_at ? ` ${when(s.last_run.finished_at, now)}` : ""}` : ""
|
||||||
|
const where = s.runs_on ? ` · on ${s.runs_on.device_name || "a computer"}${s.runs_on.cwd ? ` in ${s.runs_on.cwd}` : ""}` : ""
|
||||||
|
const watch = s.watch ? ` · watches ${s.watch.kind === "url" ? s.watch.url : s.watch.command}` : ""
|
||||||
|
out.push(`${s.title} (${s.id}) — ${s.description || "no rule"}`)
|
||||||
|
out.push(` ${state}${last}${where}${watch}`)
|
||||||
|
}
|
||||||
|
out.push("", "/schedule new <words> · pause|resume|run|history <id or title>")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/schedule history`: the newest runs first. Previews are model output: shown as plain text. */
|
||||||
|
export function historyLines(s: Schedule, runs: Run[], more: boolean, now = new Date()): string[] {
|
||||||
|
const out = [`${s.title} (${s.id}) — ${s.description || "no rule"}`]
|
||||||
|
if (!runs.length) out.push(" no runs yet")
|
||||||
|
for (const r of runs) {
|
||||||
|
const at = when(r.finished_at ?? r.started_at ?? r.due_at, now)
|
||||||
|
const model = r.model?.name ? ` · ${r.model.name}` : ""
|
||||||
|
out.push(` ${at} ${runStatus(r)}${r.timing === "late" ? " (late)" : r.timing === "catch_up" ? " (caught up)" : ""}${model} [${r.id}]`)
|
||||||
|
const said = r.error?.text || (r.preview ?? "").replace(/\s+/g, " ").trim()
|
||||||
|
if (said) out.push(` ${said.length > 160 ? said.slice(0, 159) + "…" : said}`)
|
||||||
|
}
|
||||||
|
if (more) out.push(" … older runs on the server")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What compile made of the words, before anything is created. */
|
||||||
|
export function previewLines(c: Compiled, now = new Date()): string[] {
|
||||||
|
const out = [`${c.title || "(no title)"} — ${c.description || "no rule worked out"}`]
|
||||||
|
if (c.instruction) out.push(` does: ${c.instruction}`)
|
||||||
|
if (c.next_runs.length) out.push(` next: ${c.next_runs.map((t) => when(t, now)).join(", ")}${c.timezone ? ` (${c.timezone})` : ""}`)
|
||||||
|
if (!c.ok && c.reason) out.push(` ${c.reason}`)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a schedule could run here, in `cwd`: Accept scheduled work is on and remote work may use it. */
|
||||||
|
export function canRunHere(): boolean {
|
||||||
|
const s = remoteState()
|
||||||
|
return s.enabled && s.scheduled && s.roots.length > 0
|
||||||
|
}
|
||||||
@@ -371,6 +371,16 @@ export class McpManager {
|
|||||||
return r.contents.map((x) => ("text" in x && typeof x.text === "string" ? x.text : "")).join("\n").trim()
|
return r.contents.map((x) => ("text" in x && typeof x.text === "string" ? x.text : "")).join("\n").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A tool called directly (not by the model): `/memory` changing the LLeMbas server's memory.
|
||||||
|
* The text it answered; a tool error is thrown with its text. */
|
||||||
|
async callText(server: string, tool: string, args: Record<string, unknown>, scope?: string): Promise<string> {
|
||||||
|
const c = await this.clientOf(server)
|
||||||
|
const r = (await c.callTool({ name: tool, arguments: args, ...(scope ? { _meta: { lembas: { scope } } } : {}) })) as { content?: { type: string; text?: string }[]; isError?: boolean }
|
||||||
|
const text = (r.content ?? []).map((x) => (x.type === "text" && typeof x.text === "string" ? x.text : "")).join("\n").trim()
|
||||||
|
if (r.isError) throw new Error(text || `${tool} failed`)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
|
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
|
||||||
tools(): Tool[] {
|
tools(): Tool[] {
|
||||||
const out: Tool[] = []
|
const out: Tool[] = []
|
||||||
|
|||||||
+12
-2
@@ -7,10 +7,11 @@
|
|||||||
// lembas remote enable | disable [--json] remote.enabled
|
// lembas remote enable | disable [--json] remote.enabled
|
||||||
// lembas remote mode <manual|edit|auto|plan> remote.max_mode
|
// lembas remote mode <manual|edit|auto|plan> remote.max_mode
|
||||||
// lembas remote terminal <on|off> remote.terminal
|
// 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
|
// 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
|
// `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 { resolve } from "node:path"
|
||||||
import { ConfigError } from "./config/load.ts"
|
import { ConfigError } from "./config/load.ts"
|
||||||
import { expandHome } from "./config/paths.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 { MODES, type Mode } from "./config/schema.ts"
|
||||||
import { updateGlobalConfig } from "./config/write.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 {
|
export interface Io {
|
||||||
out: (line: string) => void
|
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)",
|
s.roots.length ? `roots:\n${s.roots.map((r) => ` ${r}`).join("\n")}` : "roots: (none: no directory accepts remote work)",
|
||||||
`max mode: ${s.max_mode}`,
|
`max mode: ${s.max_mode}`,
|
||||||
`terminal: ${s.terminal ? "on" : "off"}`,
|
`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()
|
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)
|
io.err(REMOTE_USAGE)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ export const TUI_COMMAND_NAMES = [
|
|||||||
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
|
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
|
||||||
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
|
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
|
||||||
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
|
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
|
||||||
"icons", "trust", "reload", "restart", "quit", "exit", "q", "team",
|
"icons", "trust", "reload", "restart", "quit", "exit", "q", "team", "rename", "schedule", "schedules", "search",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
|
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
|
||||||
|
|||||||
+5
-2
@@ -515,8 +515,11 @@ export class Team {
|
|||||||
this.agents.set(member.name, member)
|
this.agents.set(member.name, member)
|
||||||
this.want(member)
|
this.want(member)
|
||||||
this.fillSlots()
|
this.fillSlots()
|
||||||
const vars = { name: member.name, role: member.role, size: this.size(), max: this.max, parallel: parallelFrom(this.o.capacity(member.model)) }
|
const vars = { name: member.name, role: member.role, size: this.size(), max: this.max, parallel: parallelFrom(this.o.capacity(member.model)), parent: parent.name }
|
||||||
return { text: fill(member.state === "working" ? TEXTS.added : TEXTS.added_waiting, vars), added: member }
|
// 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). */
|
/** team_send (§5). */
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const line = (r: Row, n: number) => {
|
|||||||
}
|
}
|
||||||
const header = (s: SessionRow) => `${s.id} · ${when(s.created)} · ${s.root}${s.title ? ` · "${s.title}"` : ""} · ${s.model}`
|
const header = (s: SessionRow) => `${s.id} · ${when(s.created)} · ${s.root}${s.title ? ` · "${s.title}"` : ""} · ${s.model}`
|
||||||
|
|
||||||
function snippet(text: string, query: string): string {
|
export function snippet(text: string, query: string): string {
|
||||||
const terms = (query.match(/"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^"|"$|\*$/g, "").toLowerCase()).filter((t) => t && !/^(and|or|not)$/.test(t))
|
const terms = (query.match(/"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^"|"$|\*$/g, "").toLowerCase()).filter((t) => t && !/^(and|or|not)$/.test(t))
|
||||||
const low = text.toLowerCase()
|
const low = text.toLowerCase()
|
||||||
const at = Math.max(0, Math.min(...terms.map((t) => low.indexOf(t)).filter((i) => i >= 0), text.length))
|
const at = Math.max(0, Math.min(...terms.map((t) => low.indexOf(t)).filter((i) => i >= 0), text.length))
|
||||||
@@ -65,6 +65,25 @@ function around(rows: Row[], id: number, window: number): Row[] {
|
|||||||
return rows.slice(Math.max(0, i - window), i + window + 1)
|
return rows.slice(Math.max(0, i - window), i + window + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `/search`: the sessions whose messages match `query` (FTS5, the person's words made safe; all
|
||||||
|
* of them, else any), the best hit of each, ranked. `root`: this project's only. */
|
||||||
|
export function searchSessions(store: Store, query: string, o: { root?: string; exclude?: string[]; limit?: number } = {}): { id: string; title: string; updated: number; model: string; role: string; snippet: string }[] {
|
||||||
|
let match = ftsQuery(query)
|
||||||
|
if (!match) return []
|
||||||
|
const find = (m: string) => store.hits(m, { exclude: o.exclude, root: o.root, perSession: true, limit: o.limit ?? 20 })
|
||||||
|
let hits = find(match)
|
||||||
|
if (!hits.length && anyOf(match)) {
|
||||||
|
match = anyOf(match)!
|
||||||
|
hits = find(match)
|
||||||
|
}
|
||||||
|
return hits.flatMap((h) => {
|
||||||
|
const s = store.session(h.session_id)
|
||||||
|
if (!s) return []
|
||||||
|
const m = readable(h.json)
|
||||||
|
return [{ id: s.id, title: s.title, updated: s.updated, model: s.model, role: m.role, snippet: snippet(m.text, match) }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const sessionSearchTool = defineTool({
|
export const sessionSearchTool = defineTool({
|
||||||
...described("session_search"),
|
...described("session_search"),
|
||||||
purpose: false,
|
purpose: false,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { PlanPanel } from "./components/plan.tsx"
|
|||||||
import { JobView } from "./components/jobview.tsx"
|
import { JobView } from "./components/jobview.tsx"
|
||||||
import { SubagentView } from "./components/subagent.tsx"
|
import { SubagentView } from "./components/subagent.tsx"
|
||||||
import { Bus } from "../bus/index.ts"
|
import { Bus } from "../bus/index.ts"
|
||||||
|
import { FocusWatch, noticeSequence, noticeText } from "./notify.ts"
|
||||||
import { Prompt, type PaletteEntry } from "./components/prompt.tsx"
|
import { Prompt, type PaletteEntry } from "./components/prompt.tsx"
|
||||||
import { FileIndex } from "../project/files.ts"
|
import { FileIndex } from "../project/files.ts"
|
||||||
import { attachmentsFor } from "../project/attach.ts"
|
import { attachmentsFor } from "../project/attach.ts"
|
||||||
@@ -79,6 +80,22 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
// model switched with git.
|
// model switched with git.
|
||||||
app.bus.on((e) => (e.type === "mode" || e.type === "done" || e.type === "team") && refresh())
|
app.bus.on((e) => (e.type === "mode" || e.type === "done" || e.type === "team") && refresh())
|
||||||
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
|
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
|
||||||
|
// The finished-reply notice (notify.ts): a reply that ends, or an answer the agent waits for,
|
||||||
|
// while the terminal is not focused — the bell and a desktop notification naming the session.
|
||||||
|
const focus = new FocusWatch((ev, fn) => renderer.on(ev, fn))
|
||||||
|
const notice = (what: "finished" | "waiting" | "failed") => {
|
||||||
|
if (focus.focused || app.loaded.config.notify === false) return
|
||||||
|
const id = app.engine.sessionId
|
||||||
|
const title = id ? app.store?.session(id)?.title : undefined
|
||||||
|
const seq = noticeSequence(noticeText(title, what))
|
||||||
|
const r = renderer as unknown as { writeOut?: (s: string) => void }
|
||||||
|
try {
|
||||||
|
if (r.writeOut) r.writeOut(seq)
|
||||||
|
else process.stdout.write(seq)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
app.bus.on((e) => e.type === "done" && e.reason !== "cancelled" && notice(e.reason === "error" ? "failed" : "finished"))
|
||||||
|
createEffect(() => (view.state.pending || view.state.question || view.state.plan) && notice("waiting"))
|
||||||
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
|
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
|
||||||
app.bus.on((e) => {
|
app.bus.on((e) => {
|
||||||
if (e.type !== "setting") return
|
if (e.type !== "setting") return
|
||||||
|
|||||||
+174
-11
@@ -515,6 +515,59 @@ export const COMMANDS: Command[] = [
|
|||||||
void c.share.remote(a !== "off").then((m) => (c.view.info(m), c.refresh()))
|
void c.share.remote(a !== "off").then((m) => (c.view.info(m), c.refresh()))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "rename",
|
||||||
|
args: "<title>",
|
||||||
|
description: "name this session (here, in /sessions, and in the web UI when it is shared)",
|
||||||
|
run(c, arg) {
|
||||||
|
const title = arg.trim().replace(/\s+/g, " ").slice(0, 200)
|
||||||
|
const id = c.app.engine.sessionId
|
||||||
|
const now = (id && c.app.store?.session(id)?.title) || ""
|
||||||
|
if (!id) return c.view.info("nothing to name yet: this session has no messages", "warn")
|
||||||
|
if (!title) return c.view.info(`usage: /rename <title>${now ? ` — now "${now}"` : ""}`, "warn")
|
||||||
|
c.app.rename(title)
|
||||||
|
c.view.info(`named "${title}"`)
|
||||||
|
c.refresh()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search",
|
||||||
|
args: "<words> [all]",
|
||||||
|
description: "find a past session by what was said in it (this project's; add `all` for every project) and resume it",
|
||||||
|
async run(c, arg) {
|
||||||
|
const store = c.app.store
|
||||||
|
if (!store) return c.view.info("sessions are not being recorded here", "warn")
|
||||||
|
const words = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
const everywhere = words.at(-1) === "all"
|
||||||
|
const query = (everywhere ? words.slice(0, -1) : words).join(" ")
|
||||||
|
if (!query) return c.view.info('usage: /search <words> [all] — plain words, "a phrase", OR, NOT', "warn")
|
||||||
|
const { searchSessions } = await import("../tool/session_search.ts")
|
||||||
|
let found: ReturnType<typeof searchSessions>
|
||||||
|
try {
|
||||||
|
found = searchSessions(store, query, { root: everywhere ? undefined : c.app.project.root, exclude: c.app.engine.sessionId ? [c.app.engine.sessionId] : [] })
|
||||||
|
} catch (e) {
|
||||||
|
return c.view.info(`/search: that could not be searched (${(e as Error).message}) — use plain words, "quoted phrases", OR and NOT`, "warn")
|
||||||
|
}
|
||||||
|
if (!found.length) return c.view.info(`nothing found for "${query}"${everywhere ? "" : " in this project's sessions — /search " + query + " all looks in every project"}`)
|
||||||
|
const other = (id: string) => store.session(id)?.root !== c.app.project.root
|
||||||
|
c.openDialog({
|
||||||
|
title: `${found.length} session${found.length === 1 ? "" : "s"} for "${query}"`,
|
||||||
|
width: 100,
|
||||||
|
options: found.map((f) => ({
|
||||||
|
label: f.title || firstUserLine(c, f.id) || f.id,
|
||||||
|
value: f.id,
|
||||||
|
description: `${new Date(f.updated).toISOString().slice(0, 16).replace("T", " ")}${other(f.id) ? ` · ${store.session(f.id)?.root}` : ""} · ${f.role}: ${f.snippet}`,
|
||||||
|
})),
|
||||||
|
onSelect: async (id) => {
|
||||||
|
if (other(id)) return c.view.info(`that session is in ${store.session(id)?.root} — resume it there (lembas -c, /sessions)`, "warn")
|
||||||
|
const why = await (c.share?.release(id) ?? Promise.resolve(undefined))
|
||||||
|
if (why) return c.view.info(`not resumed: ${why}`, "warn")
|
||||||
|
c.view.load(c.app.resume(id))
|
||||||
|
c.view.info(`resumed ${id}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "delete",
|
name: "delete",
|
||||||
description: "delete this session (here and in the web UI) and start a new one",
|
description: "delete this session (here and in the web UI) and start a new one",
|
||||||
@@ -767,24 +820,63 @@ export const COMMANDS: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "memory",
|
name: "memory",
|
||||||
description: "what is remembered across sessions — pick an entry to change or forget it",
|
description: "what is remembered across sessions — the server's memory when it keeps it, and this machine's files — pick an entry to change or forget it",
|
||||||
run(c) {
|
async run(c) {
|
||||||
const stores = [
|
const stores = [
|
||||||
{ key: "user", title: "user", store: c.app.memory, target: "user" as const },
|
{ key: "user", title: "user", store: c.app.memory, target: "user" as const },
|
||||||
{ key: "memory", title: "notes", store: c.app.memory, target: "memory" as const },
|
{ key: "memory", title: "notes", store: c.app.memory, target: "memory" as const },
|
||||||
...(c.app.projectMemory ? [{ key: "project", title: "project", store: c.app.projectMemory, target: "memory" as const }] : []),
|
...(c.app.projectMemory ? [{ key: "project", title: "project", store: c.app.projectMemory, target: "memory" as const }] : []),
|
||||||
]
|
]
|
||||||
const entries = stores.flatMap((s) => s.store.entries(s.target).map((text, i) => ({ s, text, value: `${s.key}:${i}` })))
|
// library: lembas — the account's memory on the server (harness acp.md *Memory*), read
|
||||||
|
// through its /mcp as the prompt reads it, and changed with its memory tool.
|
||||||
|
const { LIBRARY_SERVER } = await import("../mcp/index.ts")
|
||||||
|
const { MEMORY_RESOURCE } = await import("../app.ts")
|
||||||
|
const onServer = c.app.mcp.servers.has(LIBRARY_SERVER)
|
||||||
|
const scope = c.app.engine.o.toolCtx.libraryScope
|
||||||
|
let server: string[] = []
|
||||||
|
let serverError = ""
|
||||||
|
if (onServer) {
|
||||||
|
try {
|
||||||
|
server = serverEntries(await c.app.mcp.readResource(LIBRARY_SERVER, MEMORY_RESOURCE, scope))
|
||||||
|
} catch (e) {
|
||||||
|
serverError = (e as Error).message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type Entry = { title: string; text: string; value: string; change: (op: { action: "replace" | "remove"; content?: string }) => Promise<string> | string }
|
||||||
|
const local: Entry[] = stores.flatMap((s) =>
|
||||||
|
s.store.entries(s.target).map((text, i) => ({
|
||||||
|
title: s.title,
|
||||||
|
text,
|
||||||
|
value: `${s.key}:${i}`,
|
||||||
|
change: (op: { action: "replace" | "remove"; content?: string }) => {
|
||||||
|
const r = s.store.apply(s.target, [{ ...op, old_text: text }])
|
||||||
|
if (!r.ok) throw new Error(r.message)
|
||||||
|
return r.usage
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const remote: Entry[] = server.map((text, i) => ({
|
||||||
|
title: "server",
|
||||||
|
text,
|
||||||
|
value: `server:${i}`,
|
||||||
|
change: (op: { action: "replace" | "remove"; content?: string }) => c.app.mcp.callText(LIBRARY_SERVER, "memory", { target: "user", action: op.action, old_text: text, ...(op.content !== undefined ? { content: op.content } : {}) }, scope),
|
||||||
|
}))
|
||||||
|
const entries = [...remote, ...local]
|
||||||
const files = stores.map((s) => `${s.title}: ${s.store.file(s.target)} (${s.store.usage(s.target)})`).join("\n")
|
const files = stores.map((s) => `${s.title}: ${s.store.file(s.target)} (${s.store.usage(s.target)})`).join("\n")
|
||||||
if (!entries.length) return c.view.info(`nothing remembered yet — the agent saves facts with its memory tool, or tell it "remember that …"\n${files}`)
|
const where = onServer ? `the server's memory (${serverError ? `could not be read: ${serverError}` : `${server.length} entr${server.length === 1 ? "y" : "ies"}`}) is what sessions here use; this machine's files:\n${files}` : `the files, which you can also edit by hand (entries are separated by a line with §):\n${files}`
|
||||||
const change = (e: (typeof entries)[number], op: { action: "replace" | "remove"; content?: string }) => {
|
if (!entries.length) return c.view.info(`nothing remembered yet — the agent saves facts with its memory tool, or tell it "remember that …"\n${where}`)
|
||||||
const r = e.s.store.apply(e.s.target, [{ ...op, old_text: e.text }])
|
const change = async (e: Entry, op: { action: "replace" | "remove"; content?: string }) => {
|
||||||
c.view.info(r.ok ? `${e.s.title}: ${op.action === "remove" ? "forgotten" : "changed"} — the agent sees it from the next session (${r.usage})` : r.message, r.ok ? "info" : "warn")
|
try {
|
||||||
|
const said = await e.change(op)
|
||||||
|
c.view.info(`${e.title}: ${op.action === "remove" ? "forgotten" : "changed"} — the agent sees it from the next session${said && e.title !== "server" ? ` (${said})` : ""}`)
|
||||||
|
} catch (err) {
|
||||||
|
c.view.info(`${e.title}: ${(err as Error).message}`, "warn")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.openDialog({
|
c.openDialog({
|
||||||
title: "Memory — enter to change or forget",
|
title: "Memory — enter to change or forget",
|
||||||
filter: true,
|
filter: true,
|
||||||
options: entries.map((e) => ({ label: `[${e.s.title}] ${e.text.replace(/\s+/g, " ").slice(0, 90)}`, value: e.value })),
|
options: entries.map((e) => ({ label: `[${e.title}] ${e.text.replace(/\s+/g, " ").slice(0, 90)}`, value: e.value })),
|
||||||
onSelect: (v) => {
|
onSelect: (v) => {
|
||||||
const e = entries.find((x) => x.value === v)!
|
const e = entries.find((x) => x.value === v)!
|
||||||
c.openDialog({
|
c.openDialog({
|
||||||
@@ -793,12 +885,12 @@ export const COMMANDS: Command[] = [
|
|||||||
{ label: "Forget it", value: "remove" },
|
{ label: "Forget it", value: "remove" },
|
||||||
{ label: "Keep it", value: "keep" },
|
{ label: "Keep it", value: "keep" },
|
||||||
],
|
],
|
||||||
onSelect: (a) => a === "remove" && change(e, { action: "remove" }),
|
onSelect: (a) => void (a === "remove" && change(e, { action: "remove" })),
|
||||||
custom: { label: "replace with", onSubmit: (text) => change(e, { action: "replace", content: text }) },
|
custom: { label: "replace with", onSubmit: (text) => void change(e, { action: "replace", content: text }) },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
c.view.info(`the files, which you can also edit by hand (entries are separated by a line with §):\n${files}`)
|
c.view.info(where)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -998,6 +1090,64 @@ export const COMMANDS: Command[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "schedule",
|
||||||
|
aliases: ["schedules"],
|
||||||
|
args: "[list | new <words> | pause|resume|run|history <id or title>]",
|
||||||
|
description: "your schedules on the LLeMbas server: list, create from words, pause, resume, run now, history",
|
||||||
|
async run(c, arg) {
|
||||||
|
const S = await import("../lembas/schedules.ts")
|
||||||
|
const [sub = "list", ...rest] = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
const words = arg.trim().slice(sub.length).trim()
|
||||||
|
const say = (lines: string[], level?: "warn") => c.view.info(lines.join("\n"), level)
|
||||||
|
try {
|
||||||
|
if (sub === "list" || sub === "ls") return say(S.listLines(await S.listSchedules()))
|
||||||
|
if (sub === "new" || sub === "create" || sub === "add") {
|
||||||
|
if (!words) return say(["usage: /schedule new <what, and when> — e.g. /schedule new every weekday at 8:30 summarise the open issues"], "warn")
|
||||||
|
c.view.info("working out what that means…")
|
||||||
|
const compiled = await S.compileWords(words)
|
||||||
|
const preview = S.previewLines(compiled)
|
||||||
|
if (!Object.keys(compiled.rule ?? {}).length) return say([...preview, "", "No schedule made: say when it should run (\"every Monday at 9\", \"tomorrow at 7\")."], "warn")
|
||||||
|
const cwd = c.app.engine.o.toolCtx.cwd
|
||||||
|
const { coveredBy, remoteState } = await import("../config/remote.ts")
|
||||||
|
const here = S.canRunHere() && coveredBy(cwd, remoteState().roots) !== undefined
|
||||||
|
c.openDialog({
|
||||||
|
title: preview[0]!,
|
||||||
|
options: [
|
||||||
|
{ label: "Create it", value: "server", description: preview.slice(1).join(" · ") || "runs on the server" },
|
||||||
|
...(here ? [{ label: "Create it, to run on this computer", value: "here", description: `as an agent turn in ${cwd}, in Edit` }] : []),
|
||||||
|
{ label: "Cancel", value: "cancel" },
|
||||||
|
],
|
||||||
|
onSelect: (v) => {
|
||||||
|
if (v === "cancel") return c.view.info("no schedule made")
|
||||||
|
void S.createSchedule(compiled, words, v === "here" ? { cwd } : undefined)
|
||||||
|
.then((made) => c.view.info(`made ${made.title} (${made.id}) — next ${S.when(made.next_run)}`))
|
||||||
|
.catch((e) => c.view.info(`/schedule: ${(e as Error).message}`, "warn"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (["pause", "resume", "run", "history", "runs"].includes(sub)) {
|
||||||
|
const found = S.findSchedule(await S.listSchedules(), rest.join(" "))
|
||||||
|
if (typeof found === "string") return say([found], "warn")
|
||||||
|
if (sub === "pause") return say([`paused ${(await S.pauseSchedule(found.id)).title}`])
|
||||||
|
if (sub === "resume") {
|
||||||
|
const s = await S.resumeSchedule(found.id)
|
||||||
|
return say([`resumed ${s.title} — next ${S.when(s.next_run)}`])
|
||||||
|
}
|
||||||
|
if (sub === "run") {
|
||||||
|
const r = await S.runSchedule(found.id)
|
||||||
|
return say([`${found.title}: started now (${r.id}) — the next scheduled run stays as it was; /schedule history ${found.id}`])
|
||||||
|
}
|
||||||
|
const h = await S.scheduleRuns(found.id)
|
||||||
|
return say(S.historyLines(found, h.runs, h.more))
|
||||||
|
}
|
||||||
|
say([`usage: /schedule ${COMMANDS.find((x) => x.name === "schedule")!.args}`], "warn")
|
||||||
|
} catch (e) {
|
||||||
|
say([`/schedule: ${(e as Error).message}`], "warn")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "logout",
|
name: "logout",
|
||||||
args: "[connection]",
|
args: "[connection]",
|
||||||
@@ -1309,6 +1459,19 @@ export function findCommand(name: string): Command | undefined {
|
|||||||
/** The sessions /sessions offers: this project's, newest first, not the one open here — and only
|
/** The sessions /sessions offers: this project's, newest first, not the one open here — and only
|
||||||
* those somebody said something in, as `lembas sessions` and the web UI list them: every
|
* those somebody said something in, as `lembas sessions` and the web UI list them: every
|
||||||
* start and /new makes one, and the empty ones crowded out the rest as rows named by their id. */
|
* start and /new makes one, and the empty ones crowded out the rest as rows named by their id. */
|
||||||
|
/** The server's memory block (`lembas://memory`) as entries: each `- ` line starts one, and the
|
||||||
|
* lines under it belong to it; the block's own "(…N more" line is not an entry. */
|
||||||
|
export function serverEntries(block: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const line of block.split("\n")) {
|
||||||
|
if (line.startsWith("- ")) {
|
||||||
|
if (/^- \(…\d+ more/.test(line)) continue
|
||||||
|
out.push(line.slice(2))
|
||||||
|
} else if (out.length && line.trim()) out[out.length - 1] += `\n${line}`
|
||||||
|
}
|
||||||
|
return out.map((e) => e.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
function otherSessions(c: CommandCtx) {
|
function otherSessions(c: CommandCtx) {
|
||||||
return c.app.store?.sessions(40, c.app.project.root, true).filter((s) => s.id !== c.app.engine.sessionId) ?? []
|
return c.app.store?.sessions(40, c.app.project.root, true).filter((s) => s.id !== c.app.engine.sessionId) ?? []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// The finished-reply notice: when a reply finishes, or the agent waits for an answer, while the
|
||||||
|
// terminal is not focused, the bell rings and the terminal is asked for a desktop notification
|
||||||
|
// (OSC 9 — iTerm2, WezTerm, kitty, Windows Terminal, foot and others show it; the rest ignore it).
|
||||||
|
// It names the session and what happened, never what the reply says: a desktop notification may
|
||||||
|
// be seen by anyone near the screen. `notify: false` turns it off.
|
||||||
|
|
||||||
|
/** The words of the notice: the session's title (or "LLeMbas") and what happened. */
|
||||||
|
export function noticeText(title: string | undefined, what: "finished" | "waiting" | "failed"): string {
|
||||||
|
const name = (title ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 80) || "LLeMbas"
|
||||||
|
const said = what === "finished" ? "finished" : what === "waiting" ? "waits for you" : "stopped with an error"
|
||||||
|
return `${name}: ${said}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bytes sent to the terminal: OSC 9 with the text, then the bell. */
|
||||||
|
export function noticeSequence(text: string): string {
|
||||||
|
return `\x1b]9;${text.replace(/[\u0000-\u001f\u007f-\u009f;]/g, " ")}\x07\x07`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows the terminal's focus (OpenTUI's focus and blur events, from focus reporting) and says
|
||||||
|
* whether a notice is due: only once the terminal has said it lost focus — a terminal that
|
||||||
|
* reports nothing is taken as focused, so it never rings for nothing. */
|
||||||
|
export class FocusWatch {
|
||||||
|
focused = true
|
||||||
|
constructor(on?: (event: "focus" | "blur", fn: () => void) => void) {
|
||||||
|
on?.("focus", () => (this.focused = true))
|
||||||
|
on?.("blur", () => (this.focused = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -137,7 +137,7 @@ test("the device's limits: outside the roots, untrusted, a higher mode", async (
|
|||||||
const root = project()
|
const root = project()
|
||||||
const limits: Limits = { roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true }
|
const limits: Limits = { roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true }
|
||||||
const { c, init } = await client(limits)
|
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")
|
await expect(c.request("session/new", { cwd: tmpdir() })).rejects.toThrow("outside the directories")
|
||||||
const untrusted = join(root, "..", `ph-untrusted-${Date.now()}`)
|
const untrusted = join(root, "..", `ph-untrusted-${Date.now()}`)
|
||||||
mkdirSync(untrusted)
|
mkdirSync(untrusted)
|
||||||
@@ -268,7 +268,7 @@ test("the terminal: a real shell through the link, only where remote.terminal sa
|
|||||||
config(fake.url)
|
config(fake.url)
|
||||||
const root = project()
|
const root = project()
|
||||||
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
|
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")
|
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 })
|
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
|
||||||
|
|||||||
@@ -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 { progressLine, progressText, type Progress } from "../src/session/progress.ts"
|
||||||
import { checkMode, requestRefusal, tighten, whoDecides, type Grant } from "../src/team/delegation.ts"
|
import { checkMode, requestRefusal, tighten, whoDecides, type Grant } from "../src/team/delegation.ts"
|
||||||
import { fillPrompt as fillText } from "../src/prompt/fill.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>
|
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> = {
|
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,
|
hardline: (c) => hardlineCommand(c.line as string, BUILTIN_HARDLINE)?.id ?? null,
|
||||||
split: (c) => splitCommand(c.line as string),
|
split: (c) => splitCommand(c.line as string),
|
||||||
plain: (c) => plainCommands(c.line as string),
|
plain: (c) => plainCommands(c.line as string),
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// The CLI's gaps from the feature-parity audit: the finished-reply notice, /search over its own
|
||||||
|
// sessions, /rename, and the server's memory in /memory.
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { Store } from "../src/session/store.ts"
|
||||||
|
import { searchSessions } from "../src/tool/session_search.ts"
|
||||||
|
import { serverEntries } from "../src/tui/commands.ts"
|
||||||
|
import { FocusWatch, noticeSequence, noticeText } from "../src/tui/notify.ts"
|
||||||
|
|
||||||
|
test("the notice names the session and what happened, never the reply, without control bytes", () => {
|
||||||
|
expect(noticeText("Fix the router", "finished")).toBe("Fix the router: finished")
|
||||||
|
expect(noticeText(undefined, "waiting")).toBe("LLeMbas: waits for you")
|
||||||
|
expect(noticeText("a\u001b]0;evil\u0007b", "failed")).toBe("a ]0;evil b: stopped with an error")
|
||||||
|
expect(noticeSequence("x; y")).toBe("\x1b]9;x y\x07\x07")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("FocusWatch: focused until the terminal says it lost focus", () => {
|
||||||
|
const handlers: Record<string, () => void> = {}
|
||||||
|
const w = new FocusWatch((ev, fn) => (handlers[ev] = fn))
|
||||||
|
expect(w.focused).toBe(true)
|
||||||
|
handlers.blur!()
|
||||||
|
expect(w.focused).toBe(false)
|
||||||
|
handlers.focus!()
|
||||||
|
expect(w.focused).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("searchSessions: the best hit per session, this project's or every one, the current left out", () => {
|
||||||
|
const store = new Store(":memory:")
|
||||||
|
const a = store.createSession("/p", "m")
|
||||||
|
const b = store.createSession("/p", "m")
|
||||||
|
const c = store.createSession("/q", "m")
|
||||||
|
store.append(a.id, { role: "user", parts: [{ type: "text", text: "the waybread recipe needs honey" }] })
|
||||||
|
store.append(b.id, { role: "user", parts: [{ type: "text", text: "fix the router" }] })
|
||||||
|
store.append(b.id, { role: "assistant", parts: [{ type: "text", text: "the router now handles waybread routes" }] })
|
||||||
|
store.append(c.id, { role: "user", parts: [{ type: "text", text: "waybread in another project" }] })
|
||||||
|
const here = searchSessions(store, "waybread", { root: "/p" })
|
||||||
|
expect(here.map((h) => h.id).sort()).toEqual([a.id, b.id].sort())
|
||||||
|
expect(here.find((h) => h.id === b.id)!.snippet).toContain("waybread")
|
||||||
|
expect(searchSessions(store, "waybread").length).toBe(3)
|
||||||
|
expect(searchSessions(store, "waybread", { root: "/p", exclude: [a.id] }).map((h) => h.id)).toEqual([b.id])
|
||||||
|
// All the words first; any of them when that finds nothing.
|
||||||
|
expect(searchSessions(store, "honey nothingelse", { root: "/p" }).map((h) => h.id)).toEqual([a.id])
|
||||||
|
expect(searchSessions(store, " ")).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serverEntries: the memory block's `- ` lines, continuation lines kept, the 'more' line not an entry", () => {
|
||||||
|
expect(serverEntries("- likes tea\n- works on\n two lines\n- (…3 more, see your settings)")).toEqual(["likes tea", "works on\n two lines"])
|
||||||
|
expect(serverEntries("")).toEqual([])
|
||||||
|
})
|
||||||
@@ -37,7 +37,7 @@ test("list --json: one object, the defaults applied", () => {
|
|||||||
const r = run("list", "--json")
|
const r = run("list", "--json")
|
||||||
expect(r.code).toBe(0)
|
expect(r.code).toBe(0)
|
||||||
expect(r.out).toHaveLength(1)
|
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.
|
// Without --json, lines a person reads.
|
||||||
const plain = run("list")
|
const plain = run("list")
|
||||||
expect(plain.out.join("\n")).toContain("remote work: off")
|
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")
|
const r = run("add", a, link, "--json")
|
||||||
expect(r.code).toBe(0)
|
expect(r.code).toBe(0)
|
||||||
// The link is stored as where it leads.
|
// 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.
|
// Again, and a directory inside one: nothing more.
|
||||||
const again = run("add", a, join(a, "sub"))
|
const again = run("add", a, join(a, "sub"))
|
||||||
expect(again.code).toBe(0)
|
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.
|
// Turned on from outside, as Desktop does: it links again, saying the new roots, and serves.
|
||||||
run("add", a)
|
run("add", a)
|
||||||
await until(() => hellos.length === 2, "a second hello")
|
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)
|
expect(states.some((s) => s.detail === "the remote settings changed; linking again")).toBe(true)
|
||||||
await ask("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 2 } } } })
|
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]])
|
expect((await ask("_lembas/directories", {})).result.places.map((p: any) => [p.path, p.accepted])).toEqual([[a, true]])
|
||||||
|
|||||||
@@ -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: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched() } } }).catch((e: any) => e)
|
||||||
|
expect(err.message).toBe(SCHEDULED.texts.off)
|
||||||
|
expect(err.data).toEqual({ reason: "scheduled_off" })
|
||||||
|
const m: any = await c.request("_lembas/monitor", { cwd: root, command: "true" }).catch((e: any) => 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: any = await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "go" }], _meta: { lembas: { scheduled: sched() } } }).catch((e: any) => 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: any = await c.request("session/new", { cwd: root, _meta: { lembas: { scheduled: sched({ mode: "plan" }) } } }).catch((e: any) => 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: any = await c.request("session/new", { cwd: tmpdir(), _meta: { lembas: { scheduled: sched() } } }).catch((e: any) => 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: any = await c.request("_lembas/monitor", { cwd: root, command: "rm -rf build", mode: "edit" }).catch((e: any) => e)
|
||||||
|
expect(asks.data).toEqual({ reason: "permission" })
|
||||||
|
expect(asks.message).toContain("edit mode")
|
||||||
|
const plan: any = await c.request("_lembas/monitor", { cwd: root, command: "cat feed.txt", mode: "plan" }).catch((e: any) => 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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// `/schedule` over the schedule routes of /api/v2 with this device's token (harness acp.md, spec
|
||||||
|
// 3.5.0). A fake server answering with the shapes of the server's contract fixtures; the instance
|
||||||
|
// index and key are removed after each test (the suite shares one home).
|
||||||
|
import { afterEach, beforeEach, expect, test } from "bun:test"
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { paths } from "../src/config/paths.ts"
|
||||||
|
import { compileWords, createSchedule, findSchedule, historyLines, listLines, listSchedules, pauseSchedule, runSchedule, scheduleRuns, type Run, type Schedule } from "../src/lembas/schedules.ts"
|
||||||
|
|
||||||
|
const RUN: Run = {
|
||||||
|
id: "run1",
|
||||||
|
status: "ok",
|
||||||
|
timing: "late",
|
||||||
|
due_at: "2026-01-02T03:04:05+00:00",
|
||||||
|
started_at: "2026-01-02T03:04:05+00:00",
|
||||||
|
finished_at: "2026-01-02T03:04:05+00:00",
|
||||||
|
waiting_for: null,
|
||||||
|
model: { name: "GPT-OSS" },
|
||||||
|
preview: "All the waybread is wrapped.",
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
const SCHED: Schedule = {
|
||||||
|
id: "sched1",
|
||||||
|
title: "Waybread check",
|
||||||
|
instruction: "Check the waybread is wrapped.",
|
||||||
|
description: "On the second Monday of each month at 07:30",
|
||||||
|
enabled: true,
|
||||||
|
next_run: "2026-01-12T07:30:00+00:00",
|
||||||
|
last_run: RUN,
|
||||||
|
running: false,
|
||||||
|
}
|
||||||
|
const COMPILED = {
|
||||||
|
ok: true,
|
||||||
|
reason: null,
|
||||||
|
title: "Waybread check",
|
||||||
|
instruction: "Check the waybread is wrapped.",
|
||||||
|
target: "chat",
|
||||||
|
rule: { at: { nth: [{ n: 2, weekday: 0 }], times: ["07:30"] } },
|
||||||
|
description: "On the second Monday of each month at 07:30",
|
||||||
|
next_runs: ["2026-01-12T07:30:00+00:00", "2026-02-09T07:30:00+00:00"],
|
||||||
|
timezone: "UTC",
|
||||||
|
}
|
||||||
|
|
||||||
|
let stop: (() => void) | undefined
|
||||||
|
const seen: { method: string; path: string; body: any }[] = []
|
||||||
|
|
||||||
|
function server(o: { appOnly?: boolean } = {}) {
|
||||||
|
const s = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(req) {
|
||||||
|
const u = new URL(req.url)
|
||||||
|
if (req.headers.get("authorization") !== "Bearer lmb_key") return new Response("no", { status: 401 })
|
||||||
|
const body: any = req.method === "GET" ? null : await req.json().catch(() => null)
|
||||||
|
seen.push({ method: req.method, path: u.pathname + u.search, body })
|
||||||
|
// An older server: /api/v2 for an app's token only.
|
||||||
|
if (o.appOnly) return Response.json({ detail: "This token does not have the app scope." }, { status: 403 })
|
||||||
|
if (u.pathname === "/api/v2/schedules" && req.method === "GET") return Response.json({ schedules: [SCHED, { ...SCHED, id: "sched2", title: "Waybread count", enabled: false, last_run: null }] })
|
||||||
|
if (u.pathname === "/api/v2/schedules" && req.method === "POST") return Response.json({ schedule: { ...SCHED, id: "sched3", title: body.title } }, { status: 201 })
|
||||||
|
if (u.pathname === "/api/v2/schedules/compile") return Response.json(COMPILED)
|
||||||
|
if (u.pathname === "/api/v2/schedules/sched1/pause") return Response.json({ schedule: { ...SCHED, enabled: false } })
|
||||||
|
if (u.pathname === "/api/v2/schedules/sched1/run") return Response.json({ run: { ...RUN, id: "run9", status: "running" } }, { status: 202 })
|
||||||
|
if (u.pathname === "/api/v2/schedules/sched1/runs") return Response.json({ runs: [{ ...RUN, id: "run2", status: "unchanged", preview: "" }, RUN], more: true })
|
||||||
|
return Response.json({ detail: "That schedule is not available." }, { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
stop = () => s.stop(true)
|
||||||
|
mkdirSync(join(paths.config, "lembas"), { recursive: true })
|
||||||
|
writeFileSync(join(paths.config, "lembas", "example.key"), "lmb_key\n", { mode: 0o600 })
|
||||||
|
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { example: { base_url: `http://127.0.0.1:${s.port}`, connection: "example", scope: "models memory link", logged_in_at: new Date().toISOString() } } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = () => {
|
||||||
|
rmSync(join(paths.config, "lembas.json"), { force: true })
|
||||||
|
rmSync(join(paths.config, "lembas"), { recursive: true, force: true })
|
||||||
|
seen.length = 0
|
||||||
|
}
|
||||||
|
beforeEach(clean)
|
||||||
|
afterEach(() => {
|
||||||
|
stop?.()
|
||||||
|
clean()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("list, pause, run now and history go to the schedule routes with the device's token", async () => {
|
||||||
|
server()
|
||||||
|
const all = await listSchedules()
|
||||||
|
expect(all.map((s) => s.id)).toEqual(["sched1", "sched2"])
|
||||||
|
const now = new Date("2026-01-05T12:00:00Z")
|
||||||
|
const lines = listLines(all, now)
|
||||||
|
expect(lines[0]).toBe("Waybread check (sched1) — On the second Monday of each month at 07:30")
|
||||||
|
expect(lines[1]).toContain("· last ok")
|
||||||
|
expect(lines[3]).toBe(" paused")
|
||||||
|
expect((await pauseSchedule("sched1")).enabled).toBe(false)
|
||||||
|
expect((await runSchedule("sched1")).id).toBe("run9")
|
||||||
|
const h = await scheduleRuns("sched1")
|
||||||
|
const hist = historyLines(SCHED, h.runs, h.more, now)
|
||||||
|
expect(hist[1]).toContain("unchanged (nothing new)")
|
||||||
|
expect(hist).toContain(" All the waybread is wrapped.")
|
||||||
|
expect(hist.at(-1)).toBe(" … older runs on the server")
|
||||||
|
expect(seen.map((s) => `${s.method} ${s.path}`)).toEqual(["GET /api/v2/schedules", "POST /api/v2/schedules/sched1/pause", "POST /api/v2/schedules/sched1/run", "GET /api/v2/schedules/sched1/runs?limit=10"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("create from words: compile, then the rule as compiled; here: runs on this device", async () => {
|
||||||
|
server()
|
||||||
|
const c = await compileWords("every second Monday at 7:30 check the waybread")
|
||||||
|
expect(c.description).toBe("On the second Monday of each month at 07:30")
|
||||||
|
await createSchedule(c, "every second Monday at 7:30 check the waybread")
|
||||||
|
await createSchedule(c, "every second Monday", { cwd: "/work/app" })
|
||||||
|
const posts = seen.filter((s) => s.path === "/api/v2/schedules")
|
||||||
|
expect(posts[0]!.body).toEqual({ title: "Waybread check", instruction: "Check the waybread is wrapped.", request: "every second Monday at 7:30 check the waybread", rule: COMPILED.rule, target: "chat" })
|
||||||
|
expect(posts[1]!.body.runs_on).toEqual({ device: "this", cwd: "/work/app", mode: "edit" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an older server that lets only an app's token in: said plainly; not logged in: said", async () => {
|
||||||
|
server({ appOnly: true })
|
||||||
|
await expect(listSchedules()).rejects.toThrow("does not let a linked computer manage schedules")
|
||||||
|
clean()
|
||||||
|
await expect(listSchedules()).rejects.toThrow("not logged in")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("findSchedule: by id, by a title's start when only one fits, else says why", () => {
|
||||||
|
const all = [SCHED, { ...SCHED, id: "sched2", title: "Waybread count" }, { ...SCHED, id: "sched3", title: "Backup" }]
|
||||||
|
expect(findSchedule(all, "sched2")).toMatchObject({ id: "sched2" })
|
||||||
|
expect(findSchedule(all, "back")).toMatchObject({ id: "sched3" })
|
||||||
|
expect(findSchedule(all, "waybread")).toContain("fits 2 schedules")
|
||||||
|
expect(findSchedule(all, "nothing")).toContain("no schedule")
|
||||||
|
expect(findSchedule(all, "")).toContain("name a schedule")
|
||||||
|
})
|
||||||
Reference in new issue
Block a user