From 5a2d13461066029792ff22da5bed40373282f7ad Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 11:35:32 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat(frontend):=20Session::ask=5Flater=20?= =?UTF-8?q?=E2=80=94=20a=20question=20that=20waits=20for=20its=20owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ask` answers with the default when no frontend is attached, right for an interactive agent and wrong for one working while its owner is away. `ask_later` keeps the question open, with no timeout, even with nobody connected: a frontend that connects later gets it in its Hello. The session's end now ends open questions with their default (they were left pending), and a question asked on an ended session is refused at once. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- integrations/yoagent-frontend/src/session.rs | 36 ++++++++++++- .../yoagent-frontend/tests/session_test.rs | 50 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/integrations/yoagent-frontend/src/session.rs b/integrations/yoagent-frontend/src/session.rs index a658e63e..854fa1c6 100644 --- a/integrations/yoagent-frontend/src/session.rs +++ b/integrations/yoagent-frontend/src/session.rs @@ -218,6 +218,26 @@ impl Session { request: UiRequest, timeout: Duration, key: Option, + ) -> Json { + self.ask_inner(request, Some(timeout), key, false).await + } + + /// Ask, and keep the question open until it is answered — however long + /// that takes, and with no frontend attached: one that connects later + /// gets it in its `Hello`. For an agent working while its owner is away + /// (see the `handoff_agent` example). Ends with the default only when + /// the future is dropped or the session ends. A `notify` is shown and + /// returns at once. + pub async fn ask_later(&self, request: UiRequest) -> Json { + self.ask_inner(request, None, None, true).await + } + + async fn ask_inner( + &self, + request: UiRequest, + timeout: Option, + key: Option, + hold: bool, ) -> Json { let request = request.normalized(); let default = request.default_answer(); @@ -235,7 +255,12 @@ impl Session { return default; } } - if !attached { + // Checked under `ui`: the session's end clears `pending` under it + // too, so a question is either refused here or cleared there. + if self.is_closed() { + return default; + } + if !attached && !hold { drop(ui); tracing::info!( title = request.title(), @@ -295,7 +320,11 @@ impl Session { key, reason: ResolveReason::Withdrawn, }; - match tokio::time::timeout(timeout, rx).await { + let answered = match timeout { + Some(limit) => tokio::time::timeout(limit, rx).await, + None => Ok(rx.await), + }; + match answered { Ok(Ok(value)) => { close.reason = ResolveReason::Answered; value @@ -417,6 +446,9 @@ impl Drop for Ending { let mut clients = self.inner.clients.lock().unwrap(); self.inner.closed.store(true, Ordering::SeqCst); clients.retain(|_, tx| tx.send(ServerMessage::Closed).is_ok()); + // Open questions end with their default (a question held for an + // absent owner would otherwise wait forever). `clients` then `ui`. + self.inner.ui.lock().unwrap().pending.clear(); } } diff --git a/integrations/yoagent-frontend/tests/session_test.rs b/integrations/yoagent-frontend/tests/session_test.rs index 02a79ec4..2b084f8b 100644 --- a/integrations/yoagent-frontend/tests/session_test.rs +++ b/integrations/yoagent-frontend/tests/session_test.rs @@ -639,3 +639,53 @@ async fn a_select_is_asked_with_each_option_once() { }); assert_eq!(asking.await.unwrap(), json!(["b", "a"])); } + +/// `ask_later`: with no frontend attached the question waits (no default); +/// a frontend that connects later gets it in its Hello and answers it. +#[tokio::test] +async fn a_question_asked_later_waits_for_a_frontend() { + let (session, _driver) = Session::new(true); + let asking = { + let session = session.clone(); + tokio::spawn(async move { session.ask_later(confirm("Keep the old loader?")).await }) + }; + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!asking.is_finished(), "no frontend: it waits"); + let mut phone = session.connect(); + let ServerMessage::Hello { ui_requests, .. } = next(&mut phone).await else { + panic!("Hello") + }; + assert_eq!(ui_requests.len(), 1); + let id = ui_requests[0].id; + assert!(session.send(ClientMessage::UiResponse { + id, + value: serde_json::json!(true), + })); + assert_eq!(asking.await.unwrap(), serde_json::json!(true)); + assert_eq!(resolved(&mut phone).await, (id, ResolveReason::Answered)); +} + +/// A question asked later ends with its default when the session ends, and +/// none is opened after. +#[tokio::test] +async fn a_question_asked_later_ends_with_the_session() { + let (session, driver) = Session::new(true); + let asking = { + let session = session.clone(); + tokio::spawn(async move { session.ask_later(confirm("Keep the old loader?")).await }) + }; + tokio::time::sleep(Duration::from_millis(50)).await; + drop(driver); + let answer = tokio::time::timeout(Duration::from_secs(5), asking) + .await + .expect("the question ends with the session") + .unwrap(); + assert_eq!(answer, serde_json::json!(false)); + let late = tokio::time::timeout( + Duration::from_secs(5), + session.ask_later(confirm("Another?")), + ) + .await + .expect("refused at once on an ended session"); + assert_eq!(late, serde_json::json!(false)); +} From 393e604b328a6fc55fabd208b3d6498351c546eb Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 11:35:32 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat(frontend):=20handoff=5Fagent=20examp?= =?UTF-8?q?le=20=E2=80=94=20hand=20it=20a=20task=20and=20walk=20away?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A personal handoff agent on the frontend layer: it plans with the pi extension @juicesharp/rpiv-todo (unchanged; the todo-plan UI plugin draws it), researches with DSH's free search, asks the owner without waiting (`ask_owner`: the answer comes back as a steer message, mid-run or starting the next run), and ends every run with a handoff report the host completes with facts (change since the handoff, tokens, time, open questions). Guardrails: pi's approval of dangerous commands (refused with nobody connected), require_policy, turn/minute limits, a dollar budget for priced models. scripts/try-handoff.sh runs it in the current directory. The scripted --demo runs in CI; tried live with DeepSeek on a phone-sized page. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/ci.yml | 9 + CHANGELOG.md | 2 + README.md | 3 +- integrations/yoagent-frontend/CLAUDE.md | 4 +- integrations/yoagent-frontend/README.md | 46 ++ .../examples/handoff_agent.rs | 724 ++++++++++++++++++ .../plugins/package-lock.json | 47 +- .../yoagent-frontend/plugins/package.json | 4 +- .../yoagent-frontend/plugins/todo-plan.ts | 45 ++ scripts/try-handoff.sh | 27 + 10 files changed, 907 insertions(+), 4 deletions(-) create mode 100644 integrations/yoagent-frontend/examples/handoff_agent.rs create mode 100644 integrations/yoagent-frontend/plugins/todo-plan.ts create mode 100755 scripts/try-handoff.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e66ba82..4a39d222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -427,6 +427,15 @@ jobs: | sed 's/\x1b\[[0-9;]*m//g' | tee screen.txt grep -q "? Run \`rm -rf build\`?" screen.txt grep -q "bash ✗" screen.txt + - name: Handoff example (scripted; asks without waiting, the answer starts the next run) + shell: bash + timeout-minutes: 5 + run: | + mkdir -p "$RUNNER_TEMP/handoff-project" && cd "$RUNNER_TEMP/handoff-project" + git init -q && git -c user.email=ci@ci -c user.name=ci commit -q --allow-empty -m start + # The example checks its own outcome and exits non-zero on a regression. + cargo run --manifest-path "$GITHUB_WORKSPACE/$MANIFEST" --example handoff_agent -- --demo | tee out.txt + grep -q "ok: asked without waiting, answered later, two reports" out.txt # yoagent claims to be a tested GASP-conformant runtime — this job is the # test: emit an agent repo with the gasp bridge (mock provider, no network) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b50f74..5017d882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ adheres to [Semantic Versioning](https://semver.org/). - **One frontend protocol** (`integrations/yoagent-frontend`): a `Session` runs one agent for any number of frontends — a pi-tui terminal UI (a rutis plugin) and browsers over WebSocket — with commands in (prompt, steer, follow-up, abort, reset, quit), and out every event (text deltas merged), a run lifecycle that is always delivered (`runStart` / `runEnd` with an `outcome`: completed, aborted, rejected or error — also when the agent's task or the session's driver fails), plugins' questions (`uiRequest`: confirm, select, input, notify; the first answer that fits wins, a safe default without a frontend or in time; `uiResolved` says why it closed), `notice`s, and `closed`, and **UI plugins**: a rutis plugin offers the browser an ES module (`renderTool` for a tool's results, `mountPanel` for a side panel). `PluginHost` replaces the rutis setup (runtimes, loader, explicit per-row runtime routing; `load` waits until plugins run — though a TypeScript plugin reads as running before its async start-up finishes, so wait for the handlers you need — and drops a batch that fails). The browser's WebSocket needs the token in the URL the server prints. Example `coding_agent` (terminal or `--web`), tried live in Chrome. yoagent's core and yoagent-rutis's API do not depend on it. - **`minimal_browser` example**: one agent and the browser page in about 40 lines, no plugins or Node — the smallest starting point. - **Plugins reload while the agent runs**: `PluginHost::unload(id)`, `reload(id)` (the edited file's new code: a version that does not import is refused and the running one stays; one that fails when it starts leaves the plugin stopped, and the error says so) and `is_running(id)`; the example's `--watch` reloads a plugin when its file changes and tells the frontends, and the example now requires a tool policy, so a stopped policy plugin denies calls instead of letting them through. No rebuild of the host. +- **`handoff_agent` example and `scripts/try-handoff.sh`**: hand it a task and walk away. It plans with the pi extension `@juicesharp/rpiv-todo` (unchanged; drawn as a checklist by the new `todo-plan` UI plugin) and asks without waiting (`ask_owner`). The answer comes back as a steer message, mid-run or starting the next run. Every run ends with a handoff report, plus facts from the host (the change since the handoff, tokens, time, open questions). Tried live with DeepSeek on a phone-sized page. +- **`Session::ask_later`**: a question kept open until answered, even with no frontend attached (one that connects later gets it). An ended session now ends its open questions with their default, where they used to be left open. - Tool cards: a tool result's `details.view` is drawn as a card in the browser (and titles the tool line: in the browser when no UI plugin draws the tool, always in the terminal UI); questions gain `message` and multiple-choice `select` (options sent once each); the `ui` service gains `frontends()`; the terminal UI asks again on an answer that does not fit. ### yoagent-rutis (unreleased) diff --git a/README.md b/README.md index 5bc2343e..c87aa4c3 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,8 @@ over; commands and UI don't. An extension that hooks something the adapter can't refused, not half-run. How the 20 most-downloaded packages of each ecosystem fare (by monthly downloads, among npm's first 1000 search results per keyword), measured: [plugin compatibility](https://yologdev.github.io/yoagent/evals/plugin-compatibility.html). -`scripts/try-coding-agent.sh` serves a coding agent with pi and DSH plugins for your browser. +`scripts/try-coding-agent.sh` serves a coding agent with pi and DSH plugins for your browser; +`scripts/try-handoff.sh ""` hands a task to an agent that asks without waiting and reports back. ## Runs where agents run diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index f11c5dc7..1111698c 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -6,6 +6,8 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r ## Frontend layer (`integrations/yoagent-frontend/`, EXPERIMENTAL, separate crate) -`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). +`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. + +`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, installed `--legacy-peer-deps`: pi's packages come from the adapter), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, tokens, tool calls, time, open questions). Limits 60 turns / `--max-minutes`; `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). diff --git a/integrations/yoagent-frontend/README.md b/integrations/yoagent-frontend/README.md index 3a96fe94..a14d56cd 100644 --- a/integrations/yoagent-frontend/README.md +++ b/integrations/yoagent-frontend/README.md @@ -178,6 +178,52 @@ results rendered as links by the UI plugin, pi's question answered in the page. Logs (`RUST_LOG`) go to stderr with `--web` / `--demo`, else to `$TMPDIR/yoagent-coding-agent.log`, since the terminal UI owns the screen. +### `handoff_agent`: hand it a task and walk away + +A personal handoff agent. It works in the directory you start it from: + +```bash +cd my-project +path/to/yoagent/scripts/try-handoff.sh --live "move the config to TOML, keep the tests green" +path/to/yoagent/scripts/try-handoff.sh --phone --live "…" # serve on the LAN, for a phone +cargo run --example handoff_agent -- --demo # headless, scripted (CI) +``` + +- **It plans** with a pi extension used unchanged, `@juicesharp/rpiv-todo`. + The `todo-plan` UI plugin draws the plan as a checklist in the page. +- **It researches** with DSH's free web search, and works with yoagent's + file and shell tools. +- **It asks without waiting.** `ask_owner` puts a question to every + frontend and returns at once, so the agent carries on with what does not + depend on it. The question stays open while nobody is connected + (`Session::ask_later`), and a phone that connects later gets it. The + answer comes back as a steer message: picked up mid-run, or starting a new + run if the agent had stopped. A dismissed question comes back as + "skipped", and the agent decides itself and says so. +- **Guardrails:** + - A pi extension asks before dangerous commands. Those do wait, and with + nobody connected they are refused. + - Every run needs that policy (`require_policy`). + - Runs are capped in turns and minutes (`--max-minutes`), and in dollars + (`--budget-usd`) when the model has a price. DeepSeek has none in the + bundled table. +- **Every run ends with a handoff report** (Done / Waiting on you / + Decisions / Next). The agent is sent back until it writes one. The host + then adds facts the model cannot make up: the change since the handoff + started (`git diff --shortstat` and commits), tokens, tool calls, time, + and the questions still open. + +Tried live with DeepSeek and a phone-sized Chrome, on a small Python +project ("move the config from JSON to TOML"). The agent started before any +browser was attached. +- **Run 1:** it planned, migrated, verified and committed. It asked two + questions along the way without stopping, and reported them open. +- **The first answer** ("delete config.json") started run 2, which did that. +- **The second question, dismissed,** started run 3: the agent decided + itself and wrote down why. +- **While nobody was there,** `rm -rf __pycache__` was refused, and the + agent worked around it. + ## Tests ```bash diff --git a/integrations/yoagent-frontend/examples/handoff_agent.rs b/integrations/yoagent-frontend/examples/handoff_agent.rs new file mode 100644 index 00000000..24a37a79 --- /dev/null +++ b/integrations/yoagent-frontend/examples/handoff_agent.rs @@ -0,0 +1,724 @@ +//! EXPERIMENTAL. A personal handoff agent: hand it a task and walk away. +//! +//! - It plans with a pi extension, unchanged (`@juicesharp/rpiv-todo`'s +//! `todo` tool; the `todo-plan` UI plugin draws the plan in the browser), +//! researches with DSH's free web search, and works with yoagent's file +//! and shell tools in the directory it was started in. +//! - It asks without waiting: `ask_owner` puts a question to every frontend +//! — kept open while nobody is there (`Session::ask_later`) — and returns +//! at once, so the agent carries on with what does not depend on it. The +//! answer, whenever it comes, is sent in as a steer message: picked up by +//! the run in progress, or starting a new run when the agent had stopped. +//! - Guardrails: a pi extension asks before dangerous commands (those do +//! wait); every run needs that policy (`require_policy`); runs are capped +//! in turns and minutes, and in dollars when the model has a price. +//! - Every run ends with a handoff report (Done / Waiting on you / +//! Decisions / Next — the agent is sent back until it writes one), and +//! the host adds what the model cannot make up: the files changed, the +//! tokens, tool calls and time, the questions still open. +//! +//! ```bash +//! scripts/try-handoff.sh "migrate the config to TOML, keep tests green" # from the repo root +//! cargo run --example handoff_agent -- --live "…" # DeepSeek (DEEPSEEK_API_KEY) +//! cargo run --example handoff_agent -- --phone "…" # serve on the LAN, for a phone +//! cargo run --example handoff_agent -- --demo # headless, scripted (CI) +//! ``` +//! +//! `--web ADDR` (default 127.0.0.1:8787), `--no-dsh`, `--max-minutes N` +//! (30), `--budget-usd X` (2; priced models only). Without a task, give it +//! one in the page. `--phone` serves on every interface: anyone on the +//! network with the printed URL (it carries the token) drives the agent. +//! +//! Setup: `scripts/try-coding-agent.sh --setup-only` (Node 24+, npm). + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use rutis::Ctx; +use serde_json::{json, Value as Json}; +use yoagent::context::ExecutionLimits; +use yoagent::extension::{ + Extension, ExtensionError, RunContext, RunHooks, RunOutcome, StopContext, StopDecision, +}; +use yoagent::provider::mock::{MockResponse, MockToolCall}; +use yoagent::provider::{prices, MockProvider, ModelConfig}; +use yoagent::tools::default_tools; +use yoagent::{ + Agent, AgentEvent, AgentMessage, AgentTool, Content, Message, ToolContext, ToolError, + ToolResult, +}; +use yoagent_frontend::host::{PluginHost, Row}; +use yoagent_frontend::{ + services, session, web, ClientMessage, NoticeLevel, ServerMessage, Session, UiRequest, +}; +use yoagent_rutis::RutisBridge; + +type BoxError = Box; + +/// The sections every run's last message must have. +const REPORT: [&str; 4] = ["Done", "Waiting on you", "Decisions", "Next"]; + +fn here() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} +fn rutis_plugins() -> PathBuf { + here().join("../yoagent-rutis/plugins") +} + +struct Options { + key: Option, + addr: SocketAddr, + phone: bool, + demo: bool, + dsh: bool, + task: Option, + max_minutes: u64, + budget_usd: f64, +} + +fn main() -> Result<(), BoxError> { + let mut options = Options { + key: None, + addr: "127.0.0.1:8787".parse()?, + phone: false, + demo: false, + dsh: rutis_plugins().join("dsh/node_modules").exists(), + task: None, + max_minutes: 30, + budget_usd: 2.0, + }; + let mut live = false; + let mut words = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--live" => live = true, + "--demo" => options.demo = true, + "--no-dsh" => options.dsh = false, + "--phone" => { + options.phone = true; + options.addr = "0.0.0.0:8787".parse()?; + } + "--web" => options.addr = args.next().ok_or("--web needs ADDR")?.parse()?, + "--max-minutes" => { + options.max_minutes = args.next().ok_or("--max-minutes needs N")?.parse()? + } + "--budget-usd" => { + options.budget_usd = args.next().ok_or("--budget-usd needs X")?.parse()? + } + flag if flag.starts_with("--") => return Err(format!("unknown argument {flag}").into()), + word => words.push(word.to_owned()), + } + } + options.task = (!words.is_empty()).then(|| words.join(" ")); + for dir in [here().join("plugins"), rutis_plugins().join("pi")] { + if !dir.join("node_modules").exists() { + return Err(format!( + "run `npm ci` in {} first (or scripts/try-coding-agent.sh --setup-only)", + dir.display() + ) + .into()); + } + } + if live { + match std::env::var("DEEPSEEK_API_KEY") { + Ok(key) if !key.trim().is_empty() => options.key = Some(key.trim().to_owned()), + _ => return Err("--live needs DEEPSEEK_API_KEY".into()), + } + } + if std::env::var_os("NODE_OPTIONS").is_none() { + std::env::set_var("NODE_OPTIONS", "--no-warnings"); + } + let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + tracing_subscriber::EnvFilter::new("warn,yoagent::provider::registry=error") + }); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + let root = Ctx::root()?; + let result = run(&root, options).await; + tokio::time::sleep(Duration::from_millis(100)).await; + if let Err(e) = root.shutdown().await { + match &result { + Ok(()) => return Err(e.into()), + Err(_) => eprintln!("shutting the plugins down also failed: {e}"), + } + } + result + }) +} + +async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { + let cwd = std::env::current_dir()?; + // The demo ends itself with `Quit`: the agent's own tools hold session + // handles, so the driver would not end when the demo drops its own. + let (session, driver) = Session::new(options.demo); + let bridge = RutisBridge::install(root)?; + services::provide(root, &session)?; + + let mut builder = PluginHost::builder() + .node("ui", here().join("plugins")) + .node("pi", rutis_plugins().join("pi")); + if options.dsh { + builder = builder.node("dsh", rutis_plugins().join("dsh")); + } + let mut host = builder + .share("yoagent") + .share(services::FRONTEND) + .share(services::UI) + .start(root) + .await?; + let mut rows = vec![ + // pi extensions, unchanged: the plan, and approval of dangerous commands. + Row::new( + "pi", + rutis_plugins() + .join("pi/pi-extensions-adapter.ts") + .display(), + ) + .runtime("pi") + .config(json!({ + "extensions": [ + here().join("plugins/node_modules/@juicesharp/rpiv-todo/index.ts"), + here().join("plugins/pi-extensions/confirm-dangerous.ts"), + ], + "cwd": cwd, + })), + Row::new( + "pi-host-ui", + rutis_plugins().join("pi/host-ui.ts").display(), + ) + .runtime("pi"), + Row::new("todo-plan", here().join("plugins/todo-plan.ts").display()).runtime("ui"), + ]; + if options.dsh { + let dsh = |id: &str, name: &str| Row::new(id, name).runtime("dsh"); + rows.extend([ + dsh("dsh-web", "@deepseek-ai/dsh-web"), + dsh("dsh-system-prompt", "@deepseek-ai/dsh-system-prompt"), + dsh("dsh-tools", "@deepseek-ai/dsh-tools"), + dsh("dsh-free-search", "dsh-free-search").config( + json!({ "provider": "bing", "disabledEngines": [], "bingMarket": "en-US" }), + ), + Row::new( + "dsh", + rutis_plugins().join("dsh/dsh-tools-adapter.ts").display(), + ) + .runtime("dsh"), + Row::new( + "dsh-host-dialogs", + rutis_plugins().join("dsh/host-dialogs.ts").display(), + ) + .runtime("dsh"), + ]); + } + host.load(rows).await?; + // A TypeScript row runs before its async start-up is done: wait for the + // handlers the agent depends on, the pi policy above all. + let mut needed = vec!["pi-extensions"]; + if options.dsh { + needed.push("dsh-tools"); + } + let registry = bridge.registry().clone(); + tokio::time::timeout(Duration::from_secs(60), async { + while !needed + .iter() + .all(|name| registry.handlers().iter().any(|h| h.name() == *name)) + { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .map_err(|_| { + format!( + "plugins did not register their handlers: {:?}", + host.status() + ) + })?; + + // Prices are opt-in; a model with none gets no dollar cap (turns and minutes still apply). + prices::enable_bundled(); + let config = match &options.key { + Some(_) => ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash"), + None => ModelConfig::mock(), + }; + let budget = yoagent::extension::Budget::for_model(options.budget_usd, &config) + .map(|budget| budget.across_runs()); + let open = Arc::new(AtomicUsize::new(0)); + let mut agent = match &options.key { + Some(key) => Agent::from_config(config).with_api_key(key.clone()), + None => Agent::from_provider(scripted(), config), + } + .with_system_prompt(system_prompt(&cwd)) + .with_tools({ + // `with_tools` sets the whole list: yoagent's tools and `ask_owner`. + let mut tools = default_tools(); + tools.push(Box::new(AskOwner { + session: session.clone(), + next: Arc::default(), + open: open.clone(), + })); + tools + }) + .with_execution_limits( + ExecutionLimits::default() + .with_max_turns(60) + .with_max_duration(Duration::from_secs(options.max_minutes * 60)), + ) + // Questions from pi's approval wait up to the frontends' timeout. + .with_extension( + bridge + .extension() + .require_policy() + .with_policy_timeout(Some(session::UI_TIMEOUT + Duration::from_secs(30))), + ) + .with_extension(Handoff { + session: session.clone(), + // Where the handoff started: the facts compare against it, so commits the agent made count. + start: git(&cwd, &["rev-parse", "HEAD"]), + cwd: cwd.clone(), + open, + }); + let priced = budget.is_some(); + if let Some(budget) = budget { + agent = agent.with_extension(budget); + } + + if options.demo { + return demo(session, driver, agent, options.task).await; + } + let served = web::serve(session.clone(), options.addr).await?; + let url = match (options.phone, lan_ip()) { + (true, Some(ip)) => format!("http://{ip}:{}/?t={}", served.addr().port(), served.token()), + _ => served.url(), + }; + println!("handoff agent: {url}"); + println!( + " limits: {} min per run{} (Ctrl+C to stop)", + options.max_minutes, + if priced { + format!(", ${:.2} in all", options.budget_usd) + } else { + ", no dollar cap (this model has no price)".into() + } + ); + if let Some(task) = options.task { + session.send(ClientMessage::Prompt { text: task }); + } + tokio::select! { + _ = driver.run(agent) => {} + stop = tokio::signal::ctrl_c() => { + if let Err(e) = stop { + eprintln!("cannot listen for Ctrl+C ({e}); stopping"); + } + } + } + served.stop(); + Ok(()) +} + +fn system_prompt(cwd: &std::path::Path) -> String { + format!( + "You are the owner's handoff agent, working in {cwd}. The owner gave you a task and \ + is away. Work on your own:\n\ + - Plan with the `todo` tool: create the steps first, mark each in progress and done.\n\ + - When a decision is the owner's, call `ask_owner` and keep going with work that does \ + not depend on it. Never wait for an answer: it arrives later as a message starting \ + with `[Owner's answer to #N]` (or `[Owner skipped #N]`: then decide yourself and say \ + so). If everything left depends on an answer, stop.\n\ + - Prefer reversible steps; dangerous commands ask the owner and may be refused.\n\ + - End every run with a handoff report in Markdown, with these sections: {sections}. \ + Under Waiting on you, list the open questions by number.", + cwd = cwd.display(), + sections = REPORT.map(|s| format!("## {s}")).join(", "), + ) +} + +/// Puts a question to the owner and returns at once; the answer comes back +/// as a steer message. +struct AskOwner { + session: Session, + next: Arc, + /// Questions not answered yet (shared with the report). + open: Arc, +} + +#[async_trait::async_trait] +impl AgentTool for AskOwner { + fn name(&self) -> &str { + "ask_owner" + } + fn label(&self) -> &str { + "Ask the owner" + } + fn description(&self) -> &str { + "Ask the owner a question they decide, without waiting: this returns at once. Continue \ + with work that does not depend on the answer; it arrives later as a message. Give \ + `options` when the answer is one of a few choices." + } + fn parameters_schema(&self) -> Json { + json!({ + "type": "object", + "properties": { + "question": { "type": "string", "description": "The question, on its own." }, + "why": { "type": "string", "description": "What depends on it, briefly." }, + "options": { "type": "array", "items": { "type": "string" } } + }, + "required": ["question"] + }) + } + + async fn execute(&self, params: Json, _ctx: ToolContext) -> Result { + let question = params["question"] + .as_str() + .filter(|q| !q.trim().is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("`question` is required".into()))? + .to_owned(); + let why = params["why"].as_str().unwrap_or_default().to_owned(); + let options: Vec = params["options"] + .as_array() + .map(|o| { + o.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + let n = self.next.fetch_add(1, Ordering::Relaxed) + 1; + let title = format!("#{n} {question}"); + let request = if options.len() >= 2 { + UiRequest::Select { + title, + message: why, + options, + multiple: false, + } + } else { + UiRequest::Input { + title, + message: why, + placeholder: "Your answer".into(), + value: String::new(), + } + }; + let (session, open) = (self.session.clone(), self.open.clone()); + open.fetch_add(1, Ordering::Relaxed); + tokio::spawn(async move { + let answer = session.ask_later(request).await; + open.fetch_sub(1, Ordering::Relaxed); + let text = match answer.as_str().map(str::trim) { + Some(answer) if !answer.is_empty() => { + format!("[Owner's answer to #{n}] {question}\n{answer}") + } + _ => format!("[Owner skipped #{n}] {question}"), + }; + // Mid-run it is picked up between tool batches; when the agent + // had stopped, it starts a new run. + session.send(ClientMessage::Steer { text }); + }); + Ok(ToolResult { + content: vec![Content::Text { + text: format!( + "Asked the owner as #{n}. Do not wait: continue with what does not depend on \ + it. The answer will arrive as a message starting with [Owner's answer to #{n}]." + ), + }], + details: json!({ "question": n }), + }) + } +} + +/// Every run ends with the handoff report, and the facts the model cannot +/// make up, told to the frontends. +struct Handoff { + session: Session, + cwd: PathBuf, + /// The commit the handoff started from (`None`: not a git repository). + start: Option, + open: Arc, +} + +#[async_trait::async_trait] +impl Extension for Handoff { + fn name(&self) -> &str { + "handoff" + } + async fn start_run(&self, _run: &RunContext<'_>) -> Result, ExtensionError> { + Ok(Box::new(HandoffRun { + session: self.session.clone(), + cwd: self.cwd.clone(), + start: self.start.clone(), + open: self.open.clone(), + started: Instant::now(), + counts: Mutex::default(), + })) + } +} + +struct HandoffRun { + session: Session, + cwd: PathBuf, + start: Option, + open: Arc, + started: Instant, + /// Tokens in and out, tool calls. + counts: Mutex<(u64, u64, usize)>, +} + +#[async_trait::async_trait] +impl RunHooks for HandoffRun { + fn on_event(&self, event: &AgentEvent) { + let mut counts = self.counts.lock().unwrap(); + match event { + AgentEvent::MessageEnd { + message: AgentMessage::Llm(Message::Assistant { usage, .. }), + } => { + counts.0 += usage.input + usage.cache_read; + counts.1 += usage.output; + } + AgentEvent::ToolExecutionEnd { .. } => counts.2 += 1, + _ => {} + } + } + + async fn on_stop(&mut self, stop: &StopContext<'_>) -> StopDecision { + let Message::Assistant { content, .. } = stop.answer else { + return StopDecision::Accept; + }; + let text: String = content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.to_lowercase()), + _ => None, + }) + .collect(); + let missing: Vec<&str> = REPORT + .into_iter() + .filter(|s| !text.contains(&s.to_lowercase())) + .collect(); + if missing.is_empty() { + StopDecision::Accept + } else { + StopDecision::Continue(format!( + "End with the handoff report: sections {}. Missing: {}.", + REPORT.map(|s| format!("## {s}")).join(", "), + missing.join(", ") + )) + } + } + + async fn finish(&mut self, _outcome: &RunOutcome) { + let (tokens_in, tokens_out, tools) = *self.counts.lock().unwrap(); + // Since the handoff started: committed and uncommitted changes alike. + let changed = match &self.start { + Some(start) => { + let diff = git(&self.cwd, &["diff", "--shortstat", start]).unwrap_or_default(); + let commits = git( + &self.cwd, + &["rev-list", "--count", &format!("{start}..HEAD")], + ) + .and_then(|n| n.parse::().ok()) + .unwrap_or(0); + let diff = if diff.is_empty() { + "no changes".into() + } else { + diff + }; + format!("since the handoff: {diff}, {commits} commit(s)") + } + None => "not a git repository".into(), + }; + let new = git(&self.cwd, &["ls-files", "--others", "--exclude-standard"]) + .map(|s| s.lines().count()) + .unwrap_or(0); + let open = self.open.load(Ordering::Relaxed); + self.session.notice( + NoticeLevel::Info, + format!( + "Handoff facts: {changed}{}; {tokens_in} tokens in, {tokens_out} out; {tools} tool \ + call(s); {:.0}s; {open} question(s) waiting on you.", + if new > 0 { format!(", {new} new file(s)") } else { String::new() }, + self.started.elapsed().as_secs_f64(), + ), + ); + } +} + +fn git(cwd: &std::path::Path, args: &[&str]) -> Option { + let out = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// This machine's address on the local network (no packet is sent). +fn lan_ip() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("192.0.2.1:9").ok()?; + socket.local_addr().ok().map(|a| a.ip()) +} + +fn call(name: &str, arguments: Json) -> MockToolCall { + MockToolCall { + provider_metadata: None, + name: name.into(), + arguments, + } +} + +/// Without `--live`: a plan, a question, work that does not depend on it, a +/// report; then, once the owner answered, the rest. +fn scripted() -> MockProvider { + MockProvider::new(vec![ + MockResponse::ToolCalls(vec![call( + "todo", + json!({ "action": "create", "subject": "Move the config loader to TOML" }), + )]), + MockResponse::ToolCalls(vec![ + call( + "ask_owner", + json!({ + "question": "Keep the old YAML loader for one release?", + "why": "Deleting it breaks configs not yet migrated.", + "options": ["Keep it", "Delete it"] + }), + ), + call("list_files", json!({ "path": "." })), + ]), + MockResponse::Text( + "## Done\n- Planned the migration; listed the project.\n\ + ## Waiting on you\n- #1 Keep the old YAML loader for one release?\n\ + ## Decisions\n- None yet.\n## Next\n- Finish once #1 is answered." + .into(), + ), + MockResponse::ToolCalls(vec![call( + "todo", + json!({ "action": "update", "id": 1, "status": "completed" }), + )]), + MockResponse::Text( + "## Done\n- Moved the loader to TOML.\n## Waiting on you\n- Nothing.\n\ + ## Decisions\n- Deleted the YAML loader, as you answered.\n## Next\n- Nothing." + .into(), + ), + ]) +} + +/// `--demo`: no browser; a frontend plays the owner — it waits for the run +/// to stop, then answers the question, which starts the second run. +async fn demo( + session: Session, + driver: yoagent_frontend::Driver, + agent: Agent, + task: Option, +) -> Result<(), BoxError> { + let mut owner = session.connect(); + let running = tokio::spawn(driver.run(agent)); + let task = + task.unwrap_or_else(|| "Migrate the config loader to TOML; keep tests green.".into()); + println!("> {task}"); + session.send(ClientMessage::Prompt { text: task }); + let mut question = None; + let mut ended = 0; + let mut facts = Vec::new(); + while ended < 2 { + let message = tokio::time::timeout(Duration::from_secs(120), owner.messages.recv()) + .await + .map_err(|_| "the demo stalled")? + .ok_or("the session ended early")?; + match message { + ServerMessage::Event { event, .. } => match *event { + AgentEvent::ToolExecutionEnd { + tool_name, + is_error, + result, + .. + } => { + let why = if is_error { + let text: String = result + .content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + format!(": {}", text.chars().take(160).collect::()) + } else { + String::new() + }; + println!(" {} {tool_name}{why}", if is_error { "✗" } else { "✓" }); + } + AgentEvent::MessageEnd { + message: AgentMessage::Llm(Message::Assistant { content, .. }), + } => { + for c in &content { + if let Content::Text { text } = c { + if !text.trim().is_empty() { + println!("{}", text.trim()); + } + } + } + } + _ => {} + }, + ServerMessage::UiRequest { id, request } => { + println!(" ? (to the owner) {}", request.title()); + question = Some(id); + } + ServerMessage::Notice { message, .. } => { + println!(" [{message}]"); + facts.push(message); + } + ServerMessage::RunEnd { outcome, error, .. } => { + ended += 1; + println!( + " — run {ended} ended: {outcome:?}{}", + error.map(|e| format!(" ({e})")).unwrap_or_default() + ); + if ended == 1 { + // The owner comes back, and answers. + let id = question.ok_or("the agent asked the owner nothing")?; + println!(" ! (the owner answers #1) Delete it"); + session.send(ClientMessage::UiResponse { + id, + value: json!("Delete it"), + }); + } + } + _ => {} + } + } + drop(owner); + session.send(ClientMessage::Quit); + let agent = tokio::time::timeout(Duration::from_secs(30), running) + .await + .map_err(|_| "the session did not end")??; + let answered = agent.messages().iter().any(|m| { + matches!(m, AgentMessage::Llm(Message::User { content, .. }) + if content.iter().any(|c| matches!(c, Content::Text { text } if text.starts_with("[Owner's answer to #1]")))) + }); + if !answered { + return Err("the owner's answer never reached the agent".into()); + } + if facts + .iter() + .filter(|f| f.starts_with("Handoff facts")) + .count() + != 2 + { + return Err(format!("expected the facts after each run: {facts:?}").into()); + } + println!("ok: asked without waiting, answered later, two reports"); + Ok(()) +} diff --git a/integrations/yoagent-frontend/plugins/package-lock.json b/integrations/yoagent-frontend/plugins/package-lock.json index 56eb4ec6..f5e088b0 100644 --- a/integrations/yoagent-frontend/plugins/package-lock.json +++ b/integrations/yoagent-frontend/plugins/package-lock.json @@ -8,7 +8,9 @@ "dependencies": { "@arcships/rutis": "0.8.0", "@arcships/rutis-runtime": "0.8.0", - "@earendil-works/pi-tui": "1.1.0" + "@earendil-works/pi-tui": "1.1.0", + "@juicesharp/rpiv-i18n": "2.12.0", + "@juicesharp/rpiv-todo": "2.12.0" }, "engines": { "node": ">=24" @@ -498,6 +500,49 @@ "node": ">=18" } }, + "node_modules/@juicesharp/rpiv-config": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@juicesharp/rpiv-config/-/rpiv-config-2.12.0.tgz", + "integrity": "sha512-eGjoCDCKz2JtKIUIpZ2y8CAVjxZYXCKa2D64ByozhkAWVblXsgyFLrQMk5k5Bv5RXRrA3GY66XNG5ExtEuGASA==", + "license": "MIT", + "peerDependencies": { + "typebox": "*" + } + }, + "node_modules/@juicesharp/rpiv-i18n": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@juicesharp/rpiv-i18n/-/rpiv-i18n-2.12.0.tgz", + "integrity": "sha512-8w08+2j+fSqRTyi03bXkUQkf5HJYftLNjLNqk5xfmTy3oZ9xp7iDHtskuVQb55qQJ1k5gwKCycVezXckVqIoUQ==", + "license": "MIT", + "dependencies": { + "@juicesharp/rpiv-config": "^2.12.0" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" + } + }, + "node_modules/@juicesharp/rpiv-todo": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@juicesharp/rpiv-todo/-/rpiv-todo-2.12.0.tgz", + "integrity": "sha512-bTILerGqMGrWMUkahHzXTuYa4iPkRX/LTl12HPppcCdPg7pGwrC1K7/ZtPfp7Xi4cXzqEoyZpeUM6IA8y5gtEQ==", + "license": "MIT", + "dependencies": { + "@juicesharp/rpiv-config": "^2.12.0" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "@juicesharp/rpiv-i18n": "*", + "typebox": "*" + }, + "peerDependenciesMeta": { + "@juicesharp/rpiv-i18n": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/integrations/yoagent-frontend/plugins/package.json b/integrations/yoagent-frontend/plugins/package.json index c29e482c..ae5a4b39 100644 --- a/integrations/yoagent-frontend/plugins/package.json +++ b/integrations/yoagent-frontend/plugins/package.json @@ -9,6 +9,8 @@ "dependencies": { "@arcships/rutis": "0.8.0", "@arcships/rutis-runtime": "0.8.0", - "@earendil-works/pi-tui": "1.1.0" + "@earendil-works/pi-tui": "1.1.0", + "@juicesharp/rpiv-i18n": "2.12.0", + "@juicesharp/rpiv-todo": "2.12.0" } } diff --git a/integrations/yoagent-frontend/plugins/todo-plan.ts b/integrations/yoagent-frontend/plugins/todo-plan.ts new file mode 100644 index 00000000..66e86651 --- /dev/null +++ b/integrations/yoagent-frontend/plugins/todo-plan.ts @@ -0,0 +1,45 @@ +// EXPERIMENTAL. A UI plugin for the handoff agent: draws the plan the pi +// extension @juicesharp/rpiv-todo keeps (its `todo` tool returns the whole +// task list in `details.tasks`) as a checklist under each `todo` call, so +// the owner sees where the work stands from any browser. + +import { definePlugin } from '@arcships/rutis' + +interface Frontend { + addUiPlugin(info: { name: string; tools: string[]; panel: boolean }, module: string): () => void +} + +// The browser side. Plain DOM, no dependencies; runs in the page. +const MODULE = String.raw` +const MARK = { completed: '✓', in_progress: '▸', pending: '○' } + +export function renderTool({ result, isError }, element) { + const tasks = result?.details?.tasks + if (isError || !Array.isArray(tasks)) return + const shown = tasks.filter((task) => task.status !== 'deleted') + if (shown.length === 0) return + const done = shown.filter((task) => task.status === 'completed').length + const head = document.createElement('div') + head.textContent = 'Plan · ' + done + '/' + shown.length + ' done' + head.style.cssText = 'font-size:12px;opacity:.7;margin:4px 0 2px' + const list = document.createElement('ul') + list.style.cssText = 'margin:0 0 4px;padding-left:4px;list-style:none;font-size:13px' + for (const task of shown) { + const item = document.createElement('li') + const mark = MARK[task.status] ?? '○' + item.textContent = mark + ' ' + (task.status === 'in_progress' && task.activeForm ? task.activeForm : task.subject) + if (task.status === 'completed') item.style.opacity = '.6' + if (task.status === 'in_progress') item.style.fontWeight = '600' + list.append(item) + } + element.append(head, list) +} +` + +export default definePlugin({ + inject: ['frontend'], + apply(ctx) { + const frontend = ctx.use('frontend') + ctx.effect(frontend.addUiPlugin({ name: 'todo-plan', tools: ['todo'], panel: false }, MODULE)) + }, +}) diff --git a/scripts/try-handoff.sh b/scripts/try-handoff.sh new file mode 100755 index 00000000..5ad28033 --- /dev/null +++ b/scripts/try-handoff.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Hand a task to the handoff agent example +# (integrations/yoagent-frontend/examples/handoff_agent.rs) and walk away. It +# works in the directory you run this from — the project you hand off — and +# prints a URL to follow it, answer its questions and read its reports. +# +# cd my-project +# path/to/yoagent/scripts/try-handoff.sh "migrate the config to TOML, keep tests green" +# path/to/yoagent/scripts/try-handoff.sh --live "…" # DeepSeek (DEEPSEEK_API_KEY) +# path/to/yoagent/scripts/try-handoff.sh --phone "…" # serve on the LAN, for a phone +# +# Other flags go to the example: --demo, --no-dsh, --max-minutes N, +# --budget-usd X, --web ADDR. Needs Rust (cargo), Node 24+ and npm. +set -euo pipefail + +repo=$(cd "$(dirname "$0")/.." && pwd) +for arg in "$@"; do + case "$arg" in + -h|--help) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --live) + [ -n "${DEEPSEEK_API_KEY:-}" ] || { echo "error: --live needs DEEPSEEK_API_KEY" >&2; exit 1; } ;; + esac +done +# The plugin runtimes (installed once; checks Rust, Node and npm first). +"$repo/scripts/try-coding-agent.sh" --setup-only >/dev/null +exec cargo run --quiet --manifest-path "$repo/integrations/yoagent-frontend/Cargo.toml" \ + --example handoff_agent -- "$@" From 1596f6249b0ae609e6740f857a226fee6193cd6c Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 11:41:58 +0200 Subject: [PATCH 03/10] fix(frontend): npm ci for the plugins; a steer arriving as a run ends starts the next run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review of #293: - plugins/.npmrc `legacy-peer-deps=true`: the lock leaves out the pi packages rpiv-todo lists as peers (the pi adapter supplies them), and a plain `npm ci` — CI, try-coding-agent.sh, try-handoff.sh — refused it. - The Driver now turns steering / follow-ups the loop never read (they arrived after its last look, e.g. while extensions finished) into the next run's prompt, unless the run was aborted; a reset clears them. An owner's answer arriving just as the agent stopped used to sit until some unrelated later run. Test: a_steer_arriving_as_a_run_ends_starts_the_next_run (fails without the fix). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 2 +- integrations/yoagent-frontend/CLAUDE.md | 4 +- integrations/yoagent-frontend/plugins/.npmrc | 3 + integrations/yoagent-frontend/src/session.rs | 30 ++++++++ .../yoagent-frontend/tests/session_test.rs | 73 +++++++++++++++++++ 5 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 integrations/yoagent-frontend/plugins/.npmrc diff --git a/CHANGELOG.md b/CHANGELOG.md index 5017d882..ed339369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ adheres to [Semantic Versioning](https://semver.org/). - **`minimal_browser` example**: one agent and the browser page in about 40 lines, no plugins or Node — the smallest starting point. - **Plugins reload while the agent runs**: `PluginHost::unload(id)`, `reload(id)` (the edited file's new code: a version that does not import is refused and the running one stays; one that fails when it starts leaves the plugin stopped, and the error says so) and `is_running(id)`; the example's `--watch` reloads a plugin when its file changes and tells the frontends, and the example now requires a tool policy, so a stopped policy plugin denies calls instead of letting them through. No rebuild of the host. - **`handoff_agent` example and `scripts/try-handoff.sh`**: hand it a task and walk away. It plans with the pi extension `@juicesharp/rpiv-todo` (unchanged; drawn as a checklist by the new `todo-plan` UI plugin) and asks without waiting (`ask_owner`). The answer comes back as a steer message, mid-run or starting the next run. Every run ends with a handoff report, plus facts from the host (the change since the handoff, tokens, time, open questions). Tried live with DeepSeek on a phone-sized page. -- **`Session::ask_later`**: a question kept open until answered, even with no frontend attached (one that connects later gets it). An ended session now ends its open questions with their default, where they used to be left open. +- **`Session::ask_later`**: a question kept open until answered, even with no frontend attached (one that connects later gets it). An ended session now ends its open questions with their default, where they used to be left open. Steering or a follow-up that arrives as a run ends (too late for that run to read) now starts the next run instead of waiting for whatever run comes next. - Tool cards: a tool result's `details.view` is drawn as a card in the browser (and titles the tool line: in the browser when no UI plugin draws the tool, always in the terminal UI); questions gain `message` and multiple-choice `select` (options sent once each); the `ui` service gains `frontends()`; the terminal UI asks again on an answer that does not fit. ### yoagent-rutis (unreleased) diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index 1111698c..0d94f365 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -6,8 +6,8 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r ## Frontend layer (`integrations/yoagent-frontend/`, EXPERIMENTAL, separate crate) -`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). +`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. -`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, installed `--legacy-peer-deps`: pi's packages come from the adapter), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, tokens, tool calls, time, open questions). Limits 60 turns / `--max-minutes`; `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). +`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, `plugins/.npmrc` `legacy-peer-deps=true` — `npm ci` refuses the lock otherwise: pi's packages come from the adapter, never installed there), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, tokens, tool calls, time, open questions). Limits 60 turns / `--max-minutes`; `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). diff --git a/integrations/yoagent-frontend/plugins/.npmrc b/integrations/yoagent-frontend/plugins/.npmrc new file mode 100644 index 00000000..aeaf68f0 --- /dev/null +++ b/integrations/yoagent-frontend/plugins/.npmrc @@ -0,0 +1,3 @@ +# pi extensions installed here (@juicesharp/rpiv-todo) list pi's own packages as +# peers; the pi adapter supplies those, so they are never installed here. +legacy-peer-deps=true diff --git a/integrations/yoagent-frontend/src/session.rs b/integrations/yoagent-frontend/src/session.rs index 854fa1c6..70ca41a1 100644 --- a/integrations/yoagent-frontend/src/session.rs +++ b/integrations/yoagent-frontend/src/session.rs @@ -550,6 +550,21 @@ impl Driver { agent.finish().await; if reset { agent.clear_messages(); + agent.clear_steering_queue(); + agent.clear_follow_up_queue(); + } else if !aborted { + // Steering or a follow-up that arrived after the loop's last + // look (while the answer streamed, or extensions finished): + // the next run's prompt, not left for some later run. + let late: Vec = agent + .take_steering_queue() + .into_iter() + .chain(agent.take_follow_up_queue()) + .filter_map(|m| text_of(&m)) + .collect(); + if !late.is_empty() { + queue.push_front(late.join("\n\n")); + } } let saw_end = end.is_some(); let (stats, last) = end.unwrap_or_default(); @@ -594,6 +609,21 @@ fn user(text: String) -> AgentMessage { AgentMessage::Llm(Message::user(text)) } +/// A queued user message's text (the session only queues text). +fn text_of(message: &AgentMessage) -> Option { + let AgentMessage::Llm(Message::User { content, .. }) = message else { + return None; + }; + let text: Vec<&str> = content + .iter() + .filter_map(|c| match c { + yoagent::Content::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + (!text.is_empty()).then(|| text.join("\n")) +} + /// How the run ended, from its last assistant message only (an earlier /// message's outcome says nothing about how the run ended). fn run_error(messages: &[AgentMessage]) -> Option<(RunOutcome, String)> { diff --git a/integrations/yoagent-frontend/tests/session_test.rs b/integrations/yoagent-frontend/tests/session_test.rs index 2b084f8b..3d81ceae 100644 --- a/integrations/yoagent-frontend/tests/session_test.rs +++ b/integrations/yoagent-frontend/tests/session_test.rs @@ -689,3 +689,76 @@ async fn a_question_asked_later_ends_with_the_session() { .expect("refused at once on an ended session"); assert_eq!(late, serde_json::json!(false)); } + +/// Holds the run's `finish` until released: a window after the loop's last +/// look at the steering queue, before the run ends. +struct HoldFinish { + entered: Arc, + release: Arc, +} + +#[async_trait::async_trait] +impl yoagent::extension::Extension for HoldFinish { + fn name(&self) -> &str { + "hold-finish" + } + async fn start_run( + &self, + _run: &yoagent::extension::RunContext<'_>, + ) -> Result, yoagent::extension::ExtensionError> { + Ok(Box::new(HoldFinishRun { + entered: self.entered.clone(), + release: self.release.clone(), + })) + } +} + +struct HoldFinishRun { + entered: Arc, + release: Arc, +} + +#[async_trait::async_trait] +impl yoagent::extension::RunHooks for HoldFinishRun { + async fn finish(&mut self, _outcome: &yoagent::extension::RunOutcome) { + self.entered.notify_one(); + let _ = self.release.acquire().await.map(|p| p.forget()); + } +} + +/// A steer that arrives after the run's loop last looked (while extensions +/// finish) starts the next run instead of waiting for some later one. +#[tokio::test] +async fn a_steer_arriving_as_a_run_ends_starts_the_next_run() { + let (session, driver) = Session::new(true); + let mut ui = session.connect(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(Semaphore::new(0)); + let agent = agent(vec![ + MockResponse::Text("first".into()), + MockResponse::Text("second".into()), + ]) + .with_extension(HoldFinish { + entered: entered.clone(), + release: release.clone(), + }); + let run = tokio::spawn(driver.run(agent)); + session.send(ClientMessage::Prompt { text: "go".into() }); + tokio::time::timeout(WAIT, entered.notified()) + .await + .expect("finish starts"); + session.send(ClientMessage::Steer { + text: "late answer".into(), + }); + // Let the driver hand the steer to the agent before the run ends. + tokio::time::sleep(Duration::from_millis(100)).await; + release.add_permits(2); + until_run_end(&mut ui).await; + let seen = until_run_end(&mut ui).await; + assert!( + matches!(&seen[0], ServerMessage::RunStart { run: 2, prompt } if prompt == "late answer"), + "{seen:?}" + ); + session.send(ClientMessage::Quit); + finished(run).await; +} From 7fd985b1f0bcd8f6880b6866ca62e0ebeb661855 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 12:25:02 +0200 Subject: [PATCH 04/10] fix(frontend): questions no longer lock the page; facts count only new files - The page showed every question as a modal dialog, locking the whole page (a pi approval included) until answered. It is now docked above the composer and non-modal: the log can be read and scrolled, and the agent can be typed to, while a question waits; "1 of N questions waiting" when more queue behind it. Approvals still hold the agent, by design. - handoff_agent's facts counted every untracked file as new, including ones there before the handoff. They now count only files new since it started, and name them (up to three), so a test run's bytecode shows as what it is. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- integrations/yoagent-frontend/CLAUDE.md | 4 +-- .../examples/handoff_agent.rs | 33 +++++++++++++++--- integrations/yoagent-frontend/web/app.js | 34 +++++++++++++++---- integrations/yoagent-frontend/web/index.html | 5 +++ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index 0d94f365..650920b0 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -6,8 +6,8 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r ## Frontend layer (`integrations/yoagent-frontend/`, EXPERIMENTAL, separate crate) -`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). +`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; questions are a docked, non-modal `` (`show()`, fixed above the composer, `body.asking` pads the log, "1 of N questions waiting") — the page stays usable while one waits; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. -`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, `plugins/.npmrc` `legacy-peer-deps=true` — `npm ci` refuses the lock otherwise: pi's packages come from the adapter, never installed there), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, tokens, tool calls, time, open questions). Limits 60 turns / `--max-minutes`; `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). +`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, `plugins/.npmrc` `legacy-peer-deps=true` — `npm ci` refuses the lock otherwise: pi's packages come from the adapter, never installed there), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, untracked files new since launch (named, ≤3), tokens, tool calls, time, open questions). Limits 60 turns / `--max-minutes`; `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). diff --git a/integrations/yoagent-frontend/examples/handoff_agent.rs b/integrations/yoagent-frontend/examples/handoff_agent.rs index 24a37a79..971770a3 100644 --- a/integrations/yoagent-frontend/examples/handoff_agent.rs +++ b/integrations/yoagent-frontend/examples/handoff_agent.rs @@ -31,6 +31,7 @@ //! //! Setup: `scripts/try-coding-agent.sh --setup-only` (Node 24+, npm). +use std::collections::HashSet; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; @@ -284,6 +285,8 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { session: session.clone(), // Where the handoff started: the facts compare against it, so commits the agent made count. start: git(&cwd, &["rev-parse", "HEAD"]), + // Untracked files already there are not the agent's. + untracked: untracked(&cwd), cwd: cwd.clone(), open, }); @@ -442,6 +445,8 @@ struct Handoff { cwd: PathBuf, /// The commit the handoff started from (`None`: not a git repository). start: Option, + /// The untracked files when the handoff started. + untracked: Arc>, open: Arc, } @@ -455,6 +460,7 @@ impl Extension for Handoff { session: self.session.clone(), cwd: self.cwd.clone(), start: self.start.clone(), + untracked: self.untracked.clone(), open: self.open.clone(), started: Instant::now(), counts: Mutex::default(), @@ -466,6 +472,7 @@ struct HandoffRun { session: Session, cwd: PathBuf, start: Option, + untracked: Arc>, open: Arc, started: Instant, /// Tokens in and out, tool calls. @@ -535,22 +542,40 @@ impl RunHooks for HandoffRun { } None => "not a git repository".into(), }; - let new = git(&self.cwd, &["ls-files", "--others", "--exclude-standard"]) - .map(|s| s.lines().count()) - .unwrap_or(0); + // New since the handoff started, named (a test run's bytecode shows as what it is). + let mut new: Vec = untracked(&self.cwd) + .iter() + .filter(|f| !self.untracked.contains(*f)) + .cloned() + .collect(); + new.sort(); + let new = match new.len() { + 0 => String::new(), + n if n <= 3 => format!(", new: {}", new.join(", ")), + n => format!(", {n} new files: {}, …", new[..3].join(", ")), + }; let open = self.open.load(Ordering::Relaxed); self.session.notice( NoticeLevel::Info, format!( "Handoff facts: {changed}{}; {tokens_in} tokens in, {tokens_out} out; {tools} tool \ call(s); {:.0}s; {open} question(s) waiting on you.", - if new > 0 { format!(", {new} new file(s)") } else { String::new() }, + new, self.started.elapsed().as_secs_f64(), ), ); } } +/// The untracked files (git's ignore rules apply); empty outside a repository. +fn untracked(cwd: &std::path::Path) -> Arc> { + Arc::new( + git(cwd, &["ls-files", "--others", "--exclude-standard"]) + .map(|s| s.lines().map(str::to_owned).collect()) + .unwrap_or_default(), + ) +} + fn git(cwd: &std::path::Path, args: &[&str]) -> Option { let out = std::process::Command::new("git") .args(args) diff --git a/integrations/yoagent-frontend/web/app.js b/integrations/yoagent-frontend/web/app.js index 12edb566..43054151 100644 --- a/integrations/yoagent-frontend/web/app.js +++ b/integrations/yoagent-frontend/web/app.js @@ -269,7 +269,9 @@ function onEvent(event) { } } -// One dialog at a time, the next when it closes. +// One question at a time, the next when it closes. Docked, not modal: the +// page stays usable — read what the agent did, scroll, even type to it — +// and the question is answered when the user is ready. const questions = new Questions() function ask(id, request) { @@ -277,16 +279,33 @@ function ask(id, request) { add(request.level === 'error' ? 'notice error' : 'notice', request.message) return } - if (questions.add(id, request)) showNextQuestion() + if (questions.add(id, request)) { + showNextQuestion() + showCount() + } +} + +// "1 of 3 waiting" on the shown question, when more wait behind it. +function showCount() { + const count = dialog.querySelector('.count') + if (!count) return + const waiting = questions.waiting.length + count.textContent = waiting > 1 ? `1 of ${waiting} questions waiting` : '' +} + +function closeQuestion() { + if (dialog.open) dialog.close() + document.body.classList.remove('asking') } function resolved(id, reason) { const shown = questions.remove(id) if (reason === 'timedOut') add('notice', 'A question timed out: the safe answer was used.') if (shown && dialog.open) { - dialog.close() + closeQuestion() showNextQuestion() } + showCount() } function showNextQuestion() { @@ -294,6 +313,7 @@ function showNextQuestion() { const { id, request } = questions.current() dialog.replaceChildren() dialog.dataset.id = id + dialog.append(el('div', 'count')) const title = document.createElement('h3') title.textContent = request.title dialog.append(title) @@ -348,7 +368,9 @@ function showNextQuestion() { e.preventDefault() reply(request.kind === 'confirm' ? false : null) } - dialog.showModal() + document.body.classList.add('asking') + dialog.show() + showCount() } socket.onopen = () => (status.textContent = 'connected') @@ -357,7 +379,7 @@ socket.onclose = () => { status.textContent = token ? 'disconnected: reload to reconnect' : 'not connected: open the URL the server printed (it carries a token)' // Nothing can be answered from here any more. questions.clear() - if (dialog.open) dialog.close() + closeQuestion() } // One message at a time, in order: a message waits for the previous one // (a plugin import, say) to finish. @@ -402,7 +424,7 @@ async function handle(message) { ended = true status.textContent = 'session closed' questions.clear() - if (dialog.open) dialog.close() + closeQuestion() return default: // A newer server's message: nothing to show. diff --git a/integrations/yoagent-frontend/web/index.html b/integrations/yoagent-frontend/web/index.html index 654c5074..d2790d7b 100644 --- a/integrations/yoagent-frontend/web/index.html +++ b/integrations/yoagent-frontend/web/index.html @@ -64,6 +64,11 @@ dialog label.choice { display: block; margin: 4px 0; } dialog label.choice input { width: auto; } dialog { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--ink); max-width: 420px; width: calc(100% - 32px); } + /* Docked above the composer, not modal: the page stays usable while a question waits. */ + dialog:not(:modal) { position: fixed; left: 0; right: 0; bottom: 76px; margin: 0 auto; z-index: 10; box-shadow: 0 8px 30px rgba(0, 0, 0, .45); max-height: 60vh; overflow-y: auto; } + dialog .count { color: var(--muted); font-size: 12px; } + dialog .count:empty { display: none; } + body.asking #log { padding-bottom: 45vh; } dialog h3 { margin: 0 0 6px; font-size: 16px; } dialog .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; flex-wrap: wrap; } dialog input { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); font: inherit; } From b8df966bcc3552dffb09f962219a5dbe4814e92d Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 12:35:11 +0200 Subject: [PATCH 05/10] feat(frontend): handoff_agent as a general handoff agent, not a coding one The system prompt no longer assumes code: research, writing, planning or code; what it makes goes into files in the working directory (research with sources and how sure it is); it asks only what is the owner's to decide (preferences, budgets, taste); nothing irreversible or done on the owner's behalf (deleting, spending, sending, signing up) without their answer. README and script examples use a research task. Tried live: a headphones research task asked budget and comfort-vs-sound without waiting, used the answers as they came, and wrote a sourced report. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- integrations/yoagent-frontend/README.md | 17 +++++++++---- .../examples/handoff_agent.rs | 24 ++++++++++++------- scripts/try-handoff.sh | 4 ++-- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/integrations/yoagent-frontend/README.md b/integrations/yoagent-frontend/README.md index a14d56cd..b91cf498 100644 --- a/integrations/yoagent-frontend/README.md +++ b/integrations/yoagent-frontend/README.md @@ -180,11 +180,12 @@ Logs (`RUST_LOG`) go to stderr with `--web` / `--demo`, else to ### `handoff_agent`: hand it a task and walk away -A personal handoff agent. It works in the directory you start it from: +A personal handoff agent for any task: research, writing, planning or code. +It works in the directory you start it from, and puts what it makes there: ```bash -cd my-project -path/to/yoagent/scripts/try-handoff.sh --live "move the config to TOML, keep the tests green" +mkdir research && cd research && git init +path/to/yoagent/scripts/try-handoff.sh --live "Research the best noise-cancelling headphones right now; write headphones.md with sources. Ask me my budget, but don't wait." path/to/yoagent/scripts/try-handoff.sh --phone --live "…" # serve on the LAN, for a phone cargo run --example handoff_agent -- --demo # headless, scripted (CI) ``` @@ -213,8 +214,14 @@ cargo run --example handoff_agent -- --demo # headless, scripte started (`git diff --shortstat` and commits), tokens, tool calls, time, and the questions still open. -Tried live with DeepSeek and a phone-sized Chrome, on a small Python -project ("move the config from JSON to TOML"). The agent started before any +Tried live with DeepSeek and a phone-sized Chrome on a research task: +"the best noise-cancelling headphones, ask my budget". It asked about +budget and comfort vs sound, and kept searching while they waited. It +folded in the answers as they came, and wrote a 164-line `headphones.md` +with 33 sources and a pick matched to the answers. It also named the one +review site it could not read. + +And on a small Python project ("move the config from JSON to TOML"). The agent started before any browser was attached. - **Run 1:** it planned, migrated, verified and committed. It asked two questions along the way without stopping, and reported them open. diff --git a/integrations/yoagent-frontend/examples/handoff_agent.rs b/integrations/yoagent-frontend/examples/handoff_agent.rs index 971770a3..159aecab 100644 --- a/integrations/yoagent-frontend/examples/handoff_agent.rs +++ b/integrations/yoagent-frontend/examples/handoff_agent.rs @@ -1,4 +1,5 @@ -//! EXPERIMENTAL. A personal handoff agent: hand it a task and walk away. +//! EXPERIMENTAL. A personal handoff agent: hand it a task — research, +//! writing, planning or code — and walk away. //! //! - It plans with a pi extension, unchanged (`@juicesharp/rpiv-todo`'s //! `todo` tool; the `todo-plan` UI plugin draws the plan in the browser), @@ -330,14 +331,21 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { fn system_prompt(cwd: &std::path::Path) -> String { format!( - "You are the owner's handoff agent, working in {cwd}. The owner gave you a task and \ - is away. Work on your own:\n\ + "You are the owner's handoff agent. The owner handed you a task — research, writing, \ + planning, code, whatever it is — and went away; you finish it on your own. You can \ + search the web and fetch pages, and read, write and run things in {cwd}.\n\ - Plan with the `todo` tool: create the steps first, mark each in progress and done.\n\ - - When a decision is the owner's, call `ask_owner` and keep going with work that does \ - not depend on it. Never wait for an answer: it arrives later as a message starting \ - with `[Owner's answer to #N]` (or `[Owner skipped #N]`: then decide yourself and say \ - so). If everything left depends on an answer, stop.\n\ - - Prefer reversible steps; dangerous commands ask the owner and may be refused.\n\ + - Put what you make in files in {cwd} (notes, reports, plans, code) so the owner finds \ + it there. For research, cite your sources with links and say how sure you are.\n\ + - Decide what you can find out or reasonably choose yourself. Ask with `ask_owner` only \ + what is the owner's to decide: preferences, budgets, taste, trade-offs only they can \ + weigh. Never wait for an answer: keep going with work that does not depend on it. It \ + arrives later as a message starting with `[Owner's answer to #N]` (or `[Owner \ + skipped #N]`: then decide yourself and say so). If everything left depends on an \ + answer, stop.\n\ + - Do nothing that cannot be undone or that acts for the owner (deleting their files, \ + spending money, sending messages, signing up for things) without their answer first. \ + Some commands ask the owner for approval and may be refused.\n\ - End every run with a handoff report in Markdown, with these sections: {sections}. \ Under Waiting on you, list the open questions by number.", cwd = cwd.display(), diff --git a/scripts/try-handoff.sh b/scripts/try-handoff.sh index 5ad28033..c984a554 100755 --- a/scripts/try-handoff.sh +++ b/scripts/try-handoff.sh @@ -4,8 +4,8 @@ # works in the directory you run this from — the project you hand off — and # prints a URL to follow it, answer its questions and read its reports. # -# cd my-project -# path/to/yoagent/scripts/try-handoff.sh "migrate the config to TOML, keep tests green" +# mkdir research && cd research && git init +# path/to/yoagent/scripts/try-handoff.sh --live "research the best headphones; ask my budget" # path/to/yoagent/scripts/try-handoff.sh --live "…" # DeepSeek (DEEPSEEK_API_KEY) # path/to/yoagent/scripts/try-handoff.sh --phone "…" # serve on the LAN, for a phone # From 8c68f867263ddd923c830180af6073a39a3af40e Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 12:35:21 +0200 Subject: [PATCH 06/10] docs(scripts): try-handoff.sh help without the duplicate line Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- scripts/try-handoff.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/try-handoff.sh b/scripts/try-handoff.sh index c984a554..f0d84afe 100755 --- a/scripts/try-handoff.sh +++ b/scripts/try-handoff.sh @@ -6,7 +6,6 @@ # # mkdir research && cd research && git init # path/to/yoagent/scripts/try-handoff.sh --live "research the best headphones; ask my budget" -# path/to/yoagent/scripts/try-handoff.sh --live "…" # DeepSeek (DEEPSEEK_API_KEY) # path/to/yoagent/scripts/try-handoff.sh --phone "…" # serve on the LAN, for a phone # # Other flags go to the example: --demo, --no-dsh, --max-minutes N, @@ -16,7 +15,7 @@ set -euo pipefail repo=$(cd "$(dirname "$0")/.." && pwd) for arg in "$@"; do case "$arg" in - -h|--help) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; --live) [ -n "${DEEPSEEK_API_KEY:-}" ] || { echo "error: --live needs DEEPSEEK_API_KEY" >&2; exit 1; } ;; esac From e2975facbd8357bdd112ff8d6d2970e124c7a8bf Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 12:35:31 +0200 Subject: [PATCH 07/10] docs(scripts): try-handoff.sh help shows its last line Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- scripts/try-handoff.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/try-handoff.sh b/scripts/try-handoff.sh index f0d84afe..e5646373 100755 --- a/scripts/try-handoff.sh +++ b/scripts/try-handoff.sh @@ -15,7 +15,7 @@ set -euo pipefail repo=$(cd "$(dirname "$0")/.." && pwd) for arg in "$@"; do case "$arg" in - -h|--help) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; --live) [ -n "${DEEPSEEK_API_KEY:-}" ] || { echo "error: --live needs DEEPSEEK_API_KEY" >&2; exit 1; } ;; esac From 51a29f3b1663c8a3c83d2d3afe4c7246df750ac7 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 14:21:18 +0200 Subject: [PATCH 08/10] feat(frontend): a page opened late shows the runs so far; pi's "ignored" report at info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The session keeps its latest runs' messages (runStart, event, runEnd, notice; up to HISTORY = 5000) and replays them to a frontend that connects, after its Hello. Recorded under the clients lock, so a late frontend gets each message once (replay or live). For the handoff agent, opening the page after the agent worked shows the work, the report and the facts, not only the open question. Tests: a late frontend gets the runs so far, then live messages once; a frontend connecting after the driver died gets that run, then Closed. - The pi adapter reports what does not map (app commands, shortcuts, unfired events) at info: expected, not a fault — a WARN at every start of the handoff agent (rpiv-todo's /todos, its shortcut) read as a problem. Refusals stay warnings. pi_log_test and the matrix's classifier follow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 2 + integrations/yoagent-frontend/CLAUDE.md | 2 +- integrations/yoagent-frontend/src/session.rs | 40 ++++++++++-- .../yoagent-frontend/tests/session_test.rs | 62 ++++++++++++++++++- integrations/yoagent-rutis/CLAUDE.md | 2 +- .../plugins/pi/pi-extensions-adapter.ts | 8 ++- .../yoagent-rutis/tests/pi_log_test.rs | 5 +- scripts/ecosystem-matrix.mjs | 4 +- 8 files changed, 109 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed339369..59da9d13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,14 @@ adheres to [Semantic Versioning](https://semver.org/). - **`minimal_browser` example**: one agent and the browser page in about 40 lines, no plugins or Node — the smallest starting point. - **Plugins reload while the agent runs**: `PluginHost::unload(id)`, `reload(id)` (the edited file's new code: a version that does not import is refused and the running one stays; one that fails when it starts leaves the plugin stopped, and the error says so) and `is_running(id)`; the example's `--watch` reloads a plugin when its file changes and tells the frontends, and the example now requires a tool policy, so a stopped policy plugin denies calls instead of letting them through. No rebuild of the host. - **`handoff_agent` example and `scripts/try-handoff.sh`**: hand it a task and walk away. It plans with the pi extension `@juicesharp/rpiv-todo` (unchanged; drawn as a checklist by the new `todo-plan` UI plugin) and asks without waiting (`ask_owner`). The answer comes back as a steer message, mid-run or starting the next run. Every run ends with a handoff report, plus facts from the host (the change since the handoff, tokens, time, open questions). Tried live with DeepSeek on a phone-sized page. +- **A page opened late shows what happened**: the session keeps its latest runs' messages (up to `HISTORY`, 5000) and replays them to a frontend that connects, after its `Hello`, so a page opened mid-run or afterwards shows the runs, their tool lines, reports and notices. Questions no longer lock the page: they are docked above the input, non-modal. - **`Session::ask_later`**: a question kept open until answered, even with no frontend attached (one that connects later gets it). An ended session now ends its open questions with their default, where they used to be left open. Steering or a follow-up that arrives as a run ends (too late for that run to read) now starts the next run instead of waiting for whatever run comes next. - Tool cards: a tool result's `details.view` is drawn as a card in the browser (and titles the tool line: in the browser when no UI plugin draws the tool, always in the terminal UI); questions gain `message` and multiple-choice `select` (options sent once each); the `ui` service gains `frontends()`; the terminal UI asks again on an answer that does not fit. ### yoagent-rutis (unreleased) - **The run's conversation, on request**: a handler can ask for `messages` (yoagent's LLM messages) on the `Turn`, `ToolCall` (`before_tool`) and `Stop` it sees — `Handler::with_messages()` in Rust, `options.messages: true` for TypeScript / Python plugins. Off by default (it copies the conversation for every call); handlers that did not ask never get it. +- pi adapter: what does not map (commands, shortcuts, events yoagent never fires) is reported at `info`, not `warn`: it is expected, not a fault (refusals stay warnings). - **pi adapter: `message_end` and `context` handlers are checked, not refused.** Each message goes (a copy) to the `message_end` handlers once — the assistant message before any of its tools runs — and each request's messages to the `context` handlers; a handler that only reads works, one that would change what it got stops the adapter (yoagent cannot apply the change), and a check cut short by a timeout or cancel does not pass. The final answer is checked after it was given: a rewrite there fails the run only for a required bridge, and refuses later runs. In the [plugin compatibility](docs/evals/plugin-compatibility.md) matrix this takes pi's top 20 from 9 to 12 usable packages (pi-lens, pi-powerline-footer, context-mode; refused 8 → 5). ## yoagent-rutis 0.2.0 (2026-10-10) diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index 650920b0..4ddf23d0 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r ## Frontend layer (`integrations/yoagent-frontend/`, EXPERIMENTAL, separate crate) -`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; questions are a docked, non-modal `` (`show()`, fixed above the composer, `body.asking` pads the log, "1 of N questions waiting") — the page stays usable while one waits; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). +`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first, then the replay of `Inner.history` (the last `HISTORY` = 5000 `runStart`/`event`/`runEnd`/`notice` broadcasts, recorded in `Inner::broadcast` under `clients` then `history`, so a late frontend gets each message once; kept across a reset; questions not included — Hello carries the open ones) — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; questions are a docked, non-modal `` (`show()`, fixed above the composer, `body.asking` pads the log, "1 of N questions waiting") — the page stays usable while one waits; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. diff --git a/integrations/yoagent-frontend/src/session.rs b/integrations/yoagent-frontend/src/session.rs index 70ca41a1..b6dad58a 100644 --- a/integrations/yoagent-frontend/src/session.rs +++ b/integrations/yoagent-frontend/src/session.rs @@ -26,6 +26,11 @@ const COALESCE: Duration = Duration::from_millis(30); /// How long a plugin's question waits for an answer before the safe default. pub const UI_TIMEOUT: Duration = Duration::from_secs(300); +/// How many of the latest runs' messages (`runStart`, `event`, `runEnd`, +/// `notice`) the session keeps to replay to a frontend that connects later, +/// so a page opened mid-run or after it shows what happened. +pub const HISTORY: usize = 5000; + /// How long a withdrawal for a question not asked yet is remembered: the /// asker's `withdraw` can overtake its own `request` on the way to the host. const EARLY_WITHDRAWAL: Duration = Duration::from_secs(60); @@ -52,6 +57,10 @@ struct Inner { running: AtomicBool, /// The driver ended (or failed): nothing sent is heard any more. closed: AtomicBool, + /// The latest [`HISTORY`] run messages, oldest first. Written under the + /// `clients` lock (then this one), so a frontend connecting gets each + /// message once: in its replay, or live. + history: Mutex>, } /// The session handle. @@ -92,6 +101,7 @@ impl Session { plugin_versions: AtomicU64::new(0), running: AtomicBool::new(false), closed: AtomicBool::new(false), + history: Mutex::default(), }), commands: tx, }; @@ -106,8 +116,10 @@ impl Session { (session, driver) } - /// Attach a frontend. Its first message is [`ServerMessage::Hello`] - /// (then [`ServerMessage::Closed`] at once, if the session already ended). + /// Attach a frontend. Its first message is [`ServerMessage::Hello`], then + /// the session's recent runs as they were broadcast (up to [`HISTORY`] + /// messages, oldest first: a page opened late shows what happened), then + /// [`ServerMessage::Closed`] at once if the session already ended. pub fn connect(&self) -> Connection { let id = self.inner.next_client.fetch_add(1, Ordering::Relaxed); let (tx, rx) = mpsc::unbounded_channel(); @@ -131,6 +143,9 @@ impl Session { ui_plugins: self.ui_plugins(), ui_requests, }); + for message in self.inner.history.lock().unwrap().iter() { + let _ = tx.send(message.clone()); + } if self.inner.closed.load(Ordering::SeqCst) { let _ = tx.send(ServerMessage::Closed); } @@ -414,10 +429,23 @@ impl Session { impl Inner { fn broadcast(&self, message: ServerMessage) { - self.clients - .lock() - .unwrap() - .retain(|_, tx| tx.send(message.clone()).is_ok()); + let mut clients = self.clients.lock().unwrap(); + // Kept for later frontends: what a run showed. Questions are not + // (Hello carries the open ones), nor the session's own messages. + if matches!( + message, + ServerMessage::RunStart { .. } + | ServerMessage::Event { .. } + | ServerMessage::RunEnd { .. } + | ServerMessage::Notice { .. } + ) { + let mut history = self.history.lock().unwrap(); + if history.len() == HISTORY { + history.pop_front(); + } + history.push_back(message.clone()); + } + clients.retain(|_, tx| tx.send(message.clone()).is_ok()); } } diff --git a/integrations/yoagent-frontend/tests/session_test.rs b/integrations/yoagent-frontend/tests/session_test.rs index 3d81ceae..bfd31c24 100644 --- a/integrations/yoagent-frontend/tests/session_test.rs +++ b/integrations/yoagent-frontend/tests/session_test.rs @@ -328,10 +328,68 @@ async fn a_driver_that_dies_mid_run_ends_the_run_and_the_session() { assert!(error.is_some()); assert!(matches!(next(&mut ui).await, ServerMessage::Closed)); assert!(!session.send(ClientMessage::Prompt { text: "x".into() })); - // A frontend connecting now learns at once that the session ended. + // A frontend connecting now gets the run that died (the replay), then + // learns that the session ended. let mut late = session.connect(); assert!(matches!(next(&mut late).await, ServerMessage::Hello { .. })); - assert!(matches!(next(&mut late).await, ServerMessage::Closed)); + let mut replay = Vec::new(); + loop { + match next(&mut late).await { + ServerMessage::Closed => break, + other => replay.push(other), + } + } + assert!(matches!( + replay.first(), + Some(ServerMessage::RunStart { .. }) + )); + assert!(matches!( + replay.last(), + Some(ServerMessage::RunEnd { + outcome: RunOutcome::Error, + .. + }) + )); +} + +/// A frontend that connects after runs gets them replayed after its Hello, +/// in order, then live messages — each message once. +#[tokio::test] +async fn a_late_frontend_gets_the_runs_so_far() { + let (session, driver) = Session::new(true); + let mut early = session.connect(); + next(&mut early).await; + let run = tokio::spawn(driver.run(agent(vec![ + MockResponse::Text("first answer".into()), + MockResponse::Text("second answer".into()), + ]))); + session.send(ClientMessage::Prompt { text: "one".into() }); + let shown = until_run_end(&mut early).await; + session.notice(NoticeLevel::Info, "facts"); + next(&mut early).await; + + let mut late = session.connect(); + assert!(matches!( + next(&mut late).await, + ServerMessage::Hello { running: false, .. } + )); + // The run as the early frontend saw it, and the notice. + for message in &shown { + assert_eq!(&next(&mut late).await, message); + } + assert!( + matches!(next(&mut late).await, ServerMessage::Notice { message, .. } if message == "facts") + ); + // Then live: the next run reaches both, once. + session.send(ClientMessage::Prompt { text: "two".into() }); + let live = until_run_end(&mut late).await; + assert!( + matches!(&live[0], ServerMessage::RunStart { run: 2, .. }), + "{live:?}" + ); + assert_eq!(live, until_run_end(&mut early).await); + session.send(ClientMessage::Quit); + finished(run).await; } /// An abort mid-run is reported as such; a reset mid-run also drops the diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index 2ded7acd..b70541bb 100644 --- a/integrations/yoagent-rutis/CLAUDE.md +++ b/integrations/yoagent-rutis/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-rutis/`. The repo ## rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.8"` (0.x caret = 0.8.x; since 0.8 the packages in rutis's release train share one version; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.8"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.8 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.8.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.8.0; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `console.warn` each (`reported` set; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.8.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.8.0), `requirements.txt` (`rutis==0.8.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.8 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.8"` (0.x caret = 0.8.x; since 0.8 the packages in rutis's release train share one version; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.8"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.8 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.8.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.8.0; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.8.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.8.0), `requirements.txt` (`rutis==0.8.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.8 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### Plugin compatibility matrix diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 2a1823c9..dca9419d 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -115,8 +115,8 @@ // registered after load) refuse the load unless listed in `allowUnmapped`; the rest (observers such as `agent_end` or // `tool_execution_*`, pi's session events, the boundary events `turn_end` / // `agent_before_settle`), commands, shortcuts, flags, renderers, model -// providers, virtual models and MCP servers are reported (a warning, or a -// load failure with `strict`). One registered after load (in a handler or a +// providers, virtual models and MCP servers are reported (at info: expected, +// not a fault; a load failure with `strict`). One registered after load (in a handler or a // tool) is reported at the next model request or tool call, and a deciding // one stops the adapter: that run, and every later call, refused. Warnings // go to the host's logs (`yoagent.log`, else stderr). `pi.appendEntry` and @@ -829,7 +829,9 @@ export default definePlugin({ if (newIgnored.length > 0) { const text = `not available in yoagent, ignored: ${newIgnored.join('; ')}` if (atLoad && config.strict) throw new Error(`pi extensions use what does not map — ${text}`) - report('warn', `[pi] ${text}`) + // Expected, not a fault: app features (commands, shortcuts, events yoagent never fires) + // a library has no place for. Refusals, which are faults, stay warnings. + report('info', `[pi] ${text}`) } } diff --git a/integrations/yoagent-rutis/tests/pi_log_test.rs b/integrations/yoagent-rutis/tests/pi_log_test.rs index 3a3f9467..f29b6e52 100644 --- a/integrations/yoagent-rutis/tests/pi_log_test.rs +++ b/integrations/yoagent-rutis/tests/pi_log_test.rs @@ -102,7 +102,8 @@ async fn a_plugin_warning_reaches_the_hosts_logs() { .unwrap() .iter() .find(|l| { - l.starts_with("yoagent_rutis::plugin WARN") && l.contains("command /fixture") + // What does not map is reported at info: expected, not a fault. + l.starts_with("yoagent_rutis::plugin INFO") && l.contains("command /fixture") }) .cloned(); if let Some(line) = hit { @@ -114,7 +115,7 @@ async fn a_plugin_warning_reaches_the_hosts_logs() { .await .unwrap_or_else(|_| { panic!( - "no plugin warning in the host's logs: {:?}", + "no plugin report in the host's logs: {:?}", logs.0.lock().unwrap() ) }); diff --git a/scripts/ecosystem-matrix.mjs b/scripts/ecosystem-matrix.mjs index 632a2962..1aeef67e 100644 --- a/scripts/ecosystem-matrix.mjs +++ b/scripts/ecosystem-matrix.mjs @@ -104,7 +104,9 @@ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}` function classifyPi(r) { const warnings = (r.logs ?? []).filter((l) => l.level === 'warn').map((l) => l.message) - const ignored = warnings.flatMap((w) => (w.match(/ignored: (.*)$/)?.[1] ?? '').split('; ').filter(Boolean)).map((s) => s.replace(/^[^:]+: /, '')) + // Reported at info by the adapter now (at warn by older versions). + const reports = (r.logs ?? []).filter((l) => l.level === 'warn' || l.level === 'info').map((l) => l.message) + const ignored = reports.flatMap((w) => (w.match(/ignored: (.*)$/)?.[1] ?? '').split('; ').filter(Boolean)).map((s) => s.replace(/^[^:]+: /, '')) const skills = r.skills?.loaded ?? [] const skillErrors = r.skills?.errors ?? [] if (r.install) return { result: 'not installed', detail: r.install } From 42ca7a97b06fc3ce12037256017dc2e3173de061 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 14:44:44 +0200 Subject: [PATCH 09/10] fix(frontend): handoff_agent keeps scratch out of the owner's folder, says when a limit stopped it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a live research run that downloaded 13 raw HTML pages into the owner's folder and hit yoagent's 1M-token default before writing its report: - The prompt names a scratch folder ($TMPDIR/yoagent-handoff-) for downloads and helper scripts; only deliverables go in the working directory; the search tools (extracted text), not whole pages; the deliverable written early and kept up to date. - --max-tokens per run, 4M by default (research reads a lot; the default counts the whole prompt each turn); 80 turns. - A run a limit stopped gets a warning: "Stopped early: … Send "continue"". - A repository with no commit yet compares against git's empty tree (it read "not a git repository"). Re-run live: only headphones.md in the folder, 653k tokens, the question asked and answered while the agent kept going. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- integrations/yoagent-frontend/CLAUDE.md | 2 +- .../examples/handoff_agent.rs | 62 ++++++++++++++----- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index 4ddf23d0..2cedb79f 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -10,4 +10,4 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. -`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, `plugins/.npmrc` `legacy-peer-deps=true` — `npm ci` refuses the lock otherwise: pi's packages come from the adapter, never installed there), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, untracked files new since launch (named, ≤3), tokens, tool calls, time, open questions). Limits 60 turns / `--max-minutes`; `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). +`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, `plugins/.npmrc` `legacy-peer-deps=true` — `npm ci` refuses the lock otherwise: pi's packages come from the adapter, never installed there), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, untracked files new since launch (named, ≤3), tokens, tool calls, time, open questions). Limits 80 turns / `--max-tokens` (4M: research reads a lot; yoagent's 1M default counts the whole prompt each turn) / `--max-minutes`; a `RunEnd::Stopped` run gets a Warning notice ("Stopped early: … send continue"); scratch goes to `$TMPDIR/yoagent-handoff-` (named in the prompt: only deliverables in the cwd; search tools, not whole downloaded pages; write the deliverable early); a repo with no commit compares against git's empty tree (`EMPTY_TREE`); `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). diff --git a/integrations/yoagent-frontend/examples/handoff_agent.rs b/integrations/yoagent-frontend/examples/handoff_agent.rs index 159aecab..e294dcca 100644 --- a/integrations/yoagent-frontend/examples/handoff_agent.rs +++ b/integrations/yoagent-frontend/examples/handoff_agent.rs @@ -26,7 +26,8 @@ //! ``` //! //! `--web ADDR` (default 127.0.0.1:8787), `--no-dsh`, `--max-minutes N` -//! (30), `--budget-usd X` (2; priced models only). Without a task, give it +//! (30) and `--max-tokens N` (4M) per run, `--budget-usd X` (2; priced +//! models only). Without a task, give it //! one in the page. `--phone` serves on every interface: anyone on the //! network with the printed URL (it carries the token) drives the agent. //! @@ -60,6 +61,9 @@ use yoagent_rutis::RutisBridge; type BoxError = Box; +/// Git's empty tree: the base of a repository with no commit yet. +const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + /// The sections every run's last message must have. const REPORT: [&str; 4] = ["Done", "Waiting on you", "Decisions", "Next"]; @@ -78,6 +82,7 @@ struct Options { dsh: bool, task: Option, max_minutes: u64, + max_tokens: usize, budget_usd: f64, } @@ -90,6 +95,9 @@ fn main() -> Result<(), BoxError> { dsh: rutis_plugins().join("dsh/node_modules").exists(), task: None, max_minutes: 30, + // Research reads a lot: yoagent's default (1M per run, the whole + // prompt counted each turn) stops a long one before it writes up. + max_tokens: 4_000_000, budget_usd: 2.0, }; let mut live = false; @@ -108,6 +116,9 @@ fn main() -> Result<(), BoxError> { "--max-minutes" => { options.max_minutes = args.next().ok_or("--max-minutes needs N")?.parse()? } + "--max-tokens" => { + options.max_tokens = args.next().ok_or("--max-tokens needs N")?.parse()? + } "--budget-usd" => { options.budget_usd = args.next().ok_or("--budget-usd needs X")?.parse()? } @@ -160,6 +171,9 @@ fn main() -> Result<(), BoxError> { async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { let cwd = std::env::current_dir()?; + // The agent's own scratch (downloads, helper scripts): not the owner's folder. + let scratch = std::env::temp_dir().join(format!("yoagent-handoff-{}", std::process::id())); + std::fs::create_dir_all(&scratch)?; // The demo ends itself with `Quit`: the agent's own tools hold session // handles, so the driver would not end when the demo drops its own. let (session, driver) = Session::new(options.demo); @@ -259,7 +273,7 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { Some(key) => Agent::from_config(config).with_api_key(key.clone()), None => Agent::from_provider(scripted(), config), } - .with_system_prompt(system_prompt(&cwd)) + .with_system_prompt(system_prompt(&cwd, &scratch)) .with_tools({ // `with_tools` sets the whole list: yoagent's tools and `ask_owner`. let mut tools = default_tools(); @@ -272,7 +286,8 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { }) .with_execution_limits( ExecutionLimits::default() - .with_max_turns(60) + .with_max_turns(80) + .with_max_total_tokens(options.max_tokens) .with_max_duration(Duration::from_secs(options.max_minutes * 60)), ) // Questions from pi's approval wait up to the frontends' timeout. @@ -284,8 +299,10 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { ) .with_extension(Handoff { session: session.clone(), - // Where the handoff started: the facts compare against it, so commits the agent made count. - start: git(&cwd, &["rev-parse", "HEAD"]), + // Where the handoff started: the facts compare against it, so commits the agent made + // count. A repository with no commit yet starts from the empty tree. + start: git(&cwd, &["rev-parse", "--git-dir"]) + .map(|_| git(&cwd, &["rev-parse", "HEAD"]).unwrap_or_else(|| EMPTY_TREE.to_owned())), // Untracked files already there are not the agent's. untracked: untracked(&cwd), cwd: cwd.clone(), @@ -329,14 +346,19 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { Ok(()) } -fn system_prompt(cwd: &std::path::Path) -> String { +fn system_prompt(cwd: &std::path::Path, scratch: &std::path::Path) -> String { format!( "You are the owner's handoff agent. The owner handed you a task — research, writing, \ planning, code, whatever it is — and went away; you finish it on your own. You can \ - search the web and fetch pages, and read, write and run things in {cwd}.\n\ + search the web, and read, write and run things in {cwd}.\n\ - Plan with the `todo` tool: create the steps first, mark each in progress and done.\n\ - Put what you make in files in {cwd} (notes, reports, plans, code) so the owner finds \ - it there. For research, cite your sources with links and say how sure you are.\n\ + it there, and only that: keep your scratch (downloaded pages, helper scripts, notes to \ + yourself) in {scratch}. Write the deliverable early and update it as you go, so \ + stopping early still leaves something useful.\n\ + - For research, use the search tools (they return extracted text) rather than \ + downloading whole pages; never print a whole page. Cite your sources with links and \ + say how sure you are.\n\ - Decide what you can find out or reasonably choose yourself. Ask with `ask_owner` only \ what is the owner's to decide: preferences, budgets, taste, trade-offs only they can \ weigh. Never wait for an answer: keep going with work that does not depend on it. It \ @@ -349,6 +371,7 @@ fn system_prompt(cwd: &std::path::Path) -> String { - End every run with a handoff report in Markdown, with these sections: {sections}. \ Under Waiting on you, list the open questions by number.", cwd = cwd.display(), + scratch = scratch.display(), sections = REPORT.map(|s| format!("## {s}")).join(", "), ) } @@ -529,18 +552,27 @@ impl RunHooks for HandoffRun { } } - async fn finish(&mut self, _outcome: &RunOutcome) { + async fn finish(&mut self, outcome: &RunOutcome) { + // A run a limit stopped has no report: say so, and how to go on. + if let yoagent::extension::RunEnd::Stopped { reason } = outcome.end() { + self.session.notice( + NoticeLevel::Warning, + format!("Stopped early: {reason}. Send \"continue\" to pick up where it left off."), + ); + } let (tokens_in, tokens_out, tools) = *self.counts.lock().unwrap(); // Since the handoff started: committed and uncommitted changes alike. let changed = match &self.start { Some(start) => { let diff = git(&self.cwd, &["diff", "--shortstat", start]).unwrap_or_default(); - let commits = git( - &self.cwd, - &["rev-list", "--count", &format!("{start}..HEAD")], - ) - .and_then(|n| n.parse::().ok()) - .unwrap_or(0); + let range = if start == EMPTY_TREE { + "HEAD".to_owned() + } else { + format!("{start}..HEAD") + }; + let commits = git(&self.cwd, &["rev-list", "--count", &range]) + .and_then(|n| n.parse::().ok()) + .unwrap_or(0); let diff = if diff.is_empty() { "no changes".into() } else { From 97e7b0f2ebe5ad9f52d6de222a3b056cc1733051 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Sun, 11 Oct 2026 14:54:50 +0200 Subject: [PATCH 10/10] fix(frontend): approval gate covers destructive commands and overwrites; reset withdraws held questions; honest facts and demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review feedback on #293: - [P1] confirm-dangerous.ts asked only for `rm -r`, sudo, `chmod -R`, `git push --force`, mkfs and dd: `rm -f`, `git reset --hard`, `git clean -fd`, shell overwrites and file writes over existing files went through. It now asks for anything that could destroy or overwrite what the owner has (rm/unlink/rmdir, globs always; mv/cp/`>` onto an existing file; writes over one; reset --hard, clean, checkout --/restore, branch -D, forced push, find -delete, truncate, shred, …). Files the agent created this session and the scratch folder named in YOAGENT_SCRATCH stay free (so the deliverable can be kept up to date); paths compared via realpath. Checked against 19 commands in and outside the temp dir. - [P2] A reset left owner questions open: a late answer started a run with no context. `ask_later` now returns `Option` (None: withdrawn or the session ended) and a reset withdraws the questions held this way; ask_owner sends nothing on None. Test added. - [P2] The facts diffed against the starting commit, counting edits the owner had not committed. They now diff against a `git stash create` snapshot of the working tree (files, index and stash list untouched); commits are still counted from the starting HEAD. - The scripted demo's reports claimed a migration it never did. The demo now does what it reports: run 2 writes notes.md in the format the owner chose; the demo checks the file and that the facts name it (CI too). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 3 +- integrations/yoagent-frontend/CLAUDE.md | 2 +- integrations/yoagent-frontend/README.md | 18 ++- .../examples/handoff_agent.rs | 113 ++++++++++++----- .../pi-extensions/confirm-dangerous.ts | 116 +++++++++++++++--- integrations/yoagent-frontend/src/session.rs | 54 +++++--- .../yoagent-frontend/tests/session_test.rs | 41 ++++++- 8 files changed, 275 insertions(+), 74 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a39d222..fcd4c7f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -435,7 +435,7 @@ jobs: git init -q && git -c user.email=ci@ci -c user.name=ci commit -q --allow-empty -m start # The example checks its own outcome and exits non-zero on a regression. cargo run --manifest-path "$GITHUB_WORKSPACE/$MANIFEST" --example handoff_agent -- --demo | tee out.txt - grep -q "ok: asked without waiting, answered later, two reports" out.txt + grep -q "ok: asked without waiting, answered later, wrote notes.md, two reports" out.txt # yoagent claims to be a tested GASP-conformant runtime — this job is the # test: emit an agent repo with the gasp bridge (mock provider, no network) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59da9d13..6b4e0e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ adheres to [Semantic Versioning](https://semver.org/). - **Plugins reload while the agent runs**: `PluginHost::unload(id)`, `reload(id)` (the edited file's new code: a version that does not import is refused and the running one stays; one that fails when it starts leaves the plugin stopped, and the error says so) and `is_running(id)`; the example's `--watch` reloads a plugin when its file changes and tells the frontends, and the example now requires a tool policy, so a stopped policy plugin denies calls instead of letting them through. No rebuild of the host. - **`handoff_agent` example and `scripts/try-handoff.sh`**: hand it a task and walk away. It plans with the pi extension `@juicesharp/rpiv-todo` (unchanged; drawn as a checklist by the new `todo-plan` UI plugin) and asks without waiting (`ask_owner`). The answer comes back as a steer message, mid-run or starting the next run. Every run ends with a handoff report, plus facts from the host (the change since the handoff, tokens, time, open questions). Tried live with DeepSeek on a phone-sized page. - **A page opened late shows what happened**: the session keeps its latest runs' messages (up to `HISTORY`, 5000) and replays them to a frontend that connects, after its `Hello`, so a page opened mid-run or afterwards shows the runs, their tool lines, reports and notices. Questions no longer lock the page: they are docked above the input, non-modal. -- **`Session::ask_later`**: a question kept open until answered, even with no frontend attached (one that connects later gets it). An ended session now ends its open questions with their default, where they used to be left open. Steering or a follow-up that arrives as a run ends (too late for that run to read) now starts the next run instead of waiting for whatever run comes next. +- **`Session::ask_later`**: a question kept open until answered, even with no frontend attached (one that connects later gets it). It returns `Some(answer)`, or `None` when nobody will answer: withdrawn (a reset withdraws these, since they belong to the forgotten conversation) or the session ended. +- **The example's approval gate** (`confirm-dangerous.ts`, used by `coding_agent` and `handoff_agent`) asks before anything that could destroy or overwrite the owner's files: `rm`, `git reset --hard`, `git clean`, `git checkout --` / `git restore`, `git branch -D`, a forced push, `find -delete`, a write, `mv`/`cp` or `>` over an existing file, and more. Before, it caught only `rm -r`, `sudo`, `chmod -R`, `git push --force`, `mkfs` and `dd`. Files the agent created this session, and the scratch folder a host names in `YOAGENT_SCRATCH`, are exempt. An ended session now ends its open questions with their default, where they used to be left open. Steering or a follow-up that arrives as a run ends (too late for that run to read) now starts the next run instead of waiting for whatever run comes next. - Tool cards: a tool result's `details.view` is drawn as a card in the browser (and titles the tool line: in the browser when no UI plugin draws the tool, always in the terminal UI); questions gain `message` and multiple-choice `select` (options sent once each); the `ui` service gains `frontends()`; the terminal UI asks again on an answer that does not fit. ### yoagent-rutis (unreleased) diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index 2cedb79f..6964ae13 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -10,4 +10,4 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. -`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, `plugins/.npmrc` `legacy-peer-deps=true` — `npm ci` refuses the lock otherwise: pi's packages come from the adapter, never installed there), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, untracked files new since launch (named, ≤3), tokens, tool calls, time, open questions). Limits 80 turns / `--max-tokens` (4M: research reads a lot; yoagent's 1M default counts the whole prompt each turn) / `--max-minutes`; a `RunEnd::Stopped` run gets a Warning notice ("Stopped early: … send continue"); scratch goes to `$TMPDIR/yoagent-handoff-` (named in the prompt: only deliverables in the cwd; search tools, not whole downloaded pages; write the deliverable early); a repo with no commit compares against git's empty tree (`EMPTY_TREE`); `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). +`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, `plugins/.npmrc` `legacy-peer-deps=true` — `npm ci` refuses the lock otherwise: pi's packages come from the adapter, never installed there), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `Some(answer)` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`; `None` (withdrawn by a reset — `Inner::withdraw_held` drops `UiState.held` questions — or the session ended) → nothing). `Start { head, tree }`: `tree` = `git stash create` (a commit of the uncommitted tracked edits; files, index and stash list untouched) else `head`; the diff is against `tree`, commits counted from `head`. The scratch folder is made in `main` and exported as `YOAGENT_SCRATCH` before the runtime starts; `confirm-dangerous.ts` exempts it and files the agent created this session (paths compared via `realpath`), and asks for `rm`/`unlink`/`rmdir` (globs always), `mv`/`cp`/`>` onto an existing file, writes over an existing file, `git reset --hard`/`clean`/`checkout --`/`restore`/`branch -D`/forced push, `find -delete`, `truncate`/`shred`/`mkfs`/`dd`/`sudo`/`ch{mod,own} -R`, `.env`/`.git/`. The scripted demo does what its reports say (run 2 writes `notes.md`; checked, with the facts naming it). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, untracked files new since launch (named, ≤3), tokens, tool calls, time, open questions). Limits 80 turns / `--max-tokens` (4M: research reads a lot; yoagent's 1M default counts the whole prompt each turn) / `--max-minutes`; a `RunEnd::Stopped` run gets a Warning notice ("Stopped early: … send continue"); scratch goes to `$TMPDIR/yoagent-handoff-` (named in the prompt: only deliverables in the cwd; search tools, not whole downloaded pages; write the deliverable early); a repo with no commit compares against git's empty tree (`EMPTY_TREE`); `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). diff --git a/integrations/yoagent-frontend/README.md b/integrations/yoagent-frontend/README.md index b91cf498..627679ed 100644 --- a/integrations/yoagent-frontend/README.md +++ b/integrations/yoagent-frontend/README.md @@ -187,7 +187,7 @@ It works in the directory you start it from, and puts what it makes there: mkdir research && cd research && git init path/to/yoagent/scripts/try-handoff.sh --live "Research the best noise-cancelling headphones right now; write headphones.md with sources. Ask me my budget, but don't wait." path/to/yoagent/scripts/try-handoff.sh --phone --live "…" # serve on the LAN, for a phone -cargo run --example handoff_agent -- --demo # headless, scripted (CI) +cargo run --example handoff_agent -- --demo # headless, scripted (CI): asks, gets an answer, writes notes.md ``` - **It plans** with a pi extension used unchanged, `@juicesharp/rpiv-todo`. @@ -200,10 +200,15 @@ cargo run --example handoff_agent -- --demo # headless, scripte (`Session::ask_later`), and a phone that connects later gets it. The answer comes back as a steer message: picked up mid-run, or starting a new run if the agent had stopped. A dismissed question comes back as - "skipped", and the agent decides itself and says so. + "skipped", and the agent decides itself and says so. A reset withdraws + open questions: they belong to the conversation it forgot. - **Guardrails:** - - A pi extension asks before dangerous commands. Those do wait, and with - nobody connected they are refused. + - A pi extension (`confirm-dangerous.ts`) asks before anything that could + destroy or overwrite what you already have: `rm`, `git reset --hard`, + `git clean`, a forced push, a write or `>` over an existing file, and + more. Files the agent created this session, and its own scratch + folder, are exempt. These questions do wait, and with nobody + connected they are refused. - Every run needs that policy (`require_policy`). - Runs are capped in turns and minutes (`--max-minutes`), and in dollars (`--budget-usd`) when the model has a price. DeepSeek has none in the @@ -211,8 +216,9 @@ cargo run --example handoff_agent -- --demo # headless, scripte - **Every run ends with a handoff report** (Done / Waiting on you / Decisions / Next). The agent is sent back until it writes one. The host then adds facts the model cannot make up: the change since the handoff - started (`git diff --shortstat` and commits), tokens, tool calls, time, - and the questions still open. + started (against your working tree as it was, so edits you had not + committed are not counted), commits, new files, tokens, tool calls, + time, and the questions still open. Tried live with DeepSeek and a phone-sized Chrome on a research task: "the best noise-cancelling headphones, ask my budget". It asked about diff --git a/integrations/yoagent-frontend/examples/handoff_agent.rs b/integrations/yoagent-frontend/examples/handoff_agent.rs index e294dcca..c4cd8c2f 100644 --- a/integrations/yoagent-frontend/examples/handoff_agent.rs +++ b/integrations/yoagent-frontend/examples/handoff_agent.rs @@ -61,6 +61,30 @@ use yoagent_rutis::RutisBridge; type BoxError = Box; +/// Where a handoff started, in git. +#[derive(Clone)] +struct Start { + /// The commit (git's empty tree when the repository has none yet): + /// commits after it are the agent's. + head: String, + /// The working tree, uncommitted edits included (`git stash create` + /// stores them as a commit without touching the files, the index or + /// the stash list), else `head`: changes since it are the agent's. + tree: String, +} + +impl Start { + /// `None` outside a git repository. + fn of(cwd: &std::path::Path) -> Option { + git(cwd, &["rev-parse", "--git-dir"])?; + let head = git(cwd, &["rev-parse", "HEAD"]).unwrap_or_else(|| EMPTY_TREE.to_owned()); + let tree = git(cwd, &["stash", "create"]) + .filter(|snapshot| !snapshot.is_empty()) + .unwrap_or_else(|| head.clone()); + Some(Start { head, tree }) + } +} + /// Git's empty tree: the base of a repository with no commit yet. const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; @@ -84,6 +108,8 @@ struct Options { max_minutes: u64, max_tokens: usize, budget_usd: f64, + /// The agent's own scratch (downloads, helper scripts): not the owner's folder. + scratch: PathBuf, } fn main() -> Result<(), BoxError> { @@ -99,6 +125,7 @@ fn main() -> Result<(), BoxError> { // prompt counted each turn) stops a long one before it writes up. max_tokens: 4_000_000, budget_usd: 2.0, + scratch: std::env::temp_dir().join(format!("yoagent-handoff-{}", std::process::id())), }; let mut live = false; let mut words = Vec::new(); @@ -142,6 +169,11 @@ fn main() -> Result<(), BoxError> { _ => return Err("--live needs DEEPSEEK_API_KEY".into()), } } + std::fs::create_dir_all(&options.scratch)?; + // Before any thread starts: the plugin runtimes inherit it. The approval + // gate (confirm-dangerous.ts) lets the agent delete and overwrite freely + // there, and nowhere else it did not create. + std::env::set_var("YOAGENT_SCRATCH", &options.scratch); if std::env::var_os("NODE_OPTIONS").is_none() { std::env::set_var("NODE_OPTIONS", "--no-warnings"); } @@ -171,9 +203,7 @@ fn main() -> Result<(), BoxError> { async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { let cwd = std::env::current_dir()?; - // The agent's own scratch (downloads, helper scripts): not the owner's folder. - let scratch = std::env::temp_dir().join(format!("yoagent-handoff-{}", std::process::id())); - std::fs::create_dir_all(&scratch)?; + let scratch = options.scratch.clone(); // The demo ends itself with `Quit`: the agent's own tools hold session // handles, so the driver would not end when the demo drops its own. let (session, driver) = Session::new(options.demo); @@ -299,10 +329,7 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { ) .with_extension(Handoff { session: session.clone(), - // Where the handoff started: the facts compare against it, so commits the agent made - // count. A repository with no commit yet starts from the empty tree. - start: git(&cwd, &["rev-parse", "--git-dir"]) - .map(|_| git(&cwd, &["rev-parse", "HEAD"]).unwrap_or_else(|| EMPTY_TREE.to_owned())), + start: Start::of(&cwd), // Untracked files already there are not the agent's. untracked: untracked(&cwd), cwd: cwd.clone(), @@ -447,6 +474,9 @@ impl AgentTool for AskOwner { tokio::spawn(async move { let answer = session.ask_later(request).await; open.fetch_sub(1, Ordering::Relaxed); + // Withdrawn (a reset forgot the conversation it belongs to) or the + // session ended: nothing to tell the agent. + let Some(answer) = answer else { return }; let text = match answer.as_str().map(str::trim) { Some(answer) if !answer.is_empty() => { format!("[Owner's answer to #{n}] {question}\n{answer}") @@ -474,8 +504,8 @@ impl AgentTool for AskOwner { struct Handoff { session: Session, cwd: PathBuf, - /// The commit the handoff started from (`None`: not a git repository). - start: Option, + /// Where the handoff started (`None`: not a git repository). + start: Option, /// The untracked files when the handoff started. untracked: Arc>, open: Arc, @@ -502,7 +532,7 @@ impl Extension for Handoff { struct HandoffRun { session: Session, cwd: PathBuf, - start: Option, + start: Option, untracked: Arc>, open: Arc, started: Instant, @@ -564,11 +594,14 @@ impl RunHooks for HandoffRun { // Since the handoff started: committed and uncommitted changes alike. let changed = match &self.start { Some(start) => { - let diff = git(&self.cwd, &["diff", "--shortstat", start]).unwrap_or_default(); - let range = if start == EMPTY_TREE { + // Against the working tree as it was: edits the owner had not + // committed are not the agent's. + let diff = + git(&self.cwd, &["diff", "--shortstat", &start.tree]).unwrap_or_default(); + let range = if start.head == EMPTY_TREE { "HEAD".to_owned() } else { - format!("{start}..HEAD") + format!("{}..HEAD", start.head) }; let commits = git(&self.cwd, &["rev-list", "--count", &range]) .and_then(|n| n.parse::().ok()) @@ -642,38 +675,46 @@ fn call(name: &str, arguments: Json) -> MockToolCall { } } -/// Without `--live`: a plan, a question, work that does not depend on it, a -/// report; then, once the owner answered, the rest. +/// Without `--live`: a scripted model that does what its reports say. Run +/// 1 plans, asks which format the notes should take (without waiting), +/// lists the folder, and reports; run 2, once the owner answered, writes +/// `notes.md` in that format and reports. fn scripted() -> MockProvider { MockProvider::new(vec![ MockResponse::ToolCalls(vec![call( "todo", - json!({ "action": "create", "subject": "Move the config loader to TOML" }), + json!({ "action": "create", "subject": "List the folder and write notes.md" }), )]), MockResponse::ToolCalls(vec![ call( "ask_owner", json!({ - "question": "Keep the old YAML loader for one release?", - "why": "Deleting it breaks configs not yet migrated.", - "options": ["Keep it", "Delete it"] + "question": "Should notes.md be a bullet list or a short paragraph?", + "why": "Only the format of the notes depends on it.", + "options": ["Bullet list", "Short paragraph"] }), ), call("list_files", json!({ "path": "." })), ]), MockResponse::Text( - "## Done\n- Planned the migration; listed the project.\n\ - ## Waiting on you\n- #1 Keep the old YAML loader for one release?\n\ - ## Decisions\n- None yet.\n## Next\n- Finish once #1 is answered." + "(scripted) ## Done\n- Listed the folder.\n\ + ## Waiting on you\n- #1 Should notes.md be a bullet list or a short paragraph?\n\ + ## Decisions\n- None yet.\n## Next\n- Write notes.md once #1 is answered." .into(), ), - MockResponse::ToolCalls(vec![call( - "todo", - json!({ "action": "update", "id": 1, "status": "completed" }), - )]), + MockResponse::ToolCalls(vec![ + call( + "write_file", + json!({ + "path": "notes.md", + "content": "# Notes\n\n- Written by the handoff agent's scripted demo.\n- A bullet list, as the owner chose.\n" + }), + ), + call("todo", json!({ "action": "update", "id": 1, "status": "completed" })), + ]), MockResponse::Text( - "## Done\n- Moved the loader to TOML.\n## Waiting on you\n- Nothing.\n\ - ## Decisions\n- Deleted the YAML loader, as you answered.\n## Next\n- Nothing." + "(scripted) ## Done\n- Wrote notes.md as a bullet list.\n## Waiting on you\n- Nothing.\n\ + ## Decisions\n- A bullet list, as you answered #1.\n## Next\n- Nothing." .into(), ), ]) @@ -689,8 +730,7 @@ async fn demo( ) -> Result<(), BoxError> { let mut owner = session.connect(); let running = tokio::spawn(driver.run(agent)); - let task = - task.unwrap_or_else(|| "Migrate the config loader to TOML; keep tests green.".into()); + let task = task.unwrap_or_else(|| "Look at this folder and write notes.md about it.".into()); println!("> {task}"); session.send(ClientMessage::Prompt { text: task }); let mut question = None; @@ -754,10 +794,10 @@ async fn demo( if ended == 1 { // The owner comes back, and answers. let id = question.ok_or("the agent asked the owner nothing")?; - println!(" ! (the owner answers #1) Delete it"); + println!(" ! (the owner answers #1) Bullet list"); session.send(ClientMessage::UiResponse { id, - value: json!("Delete it"), + value: json!("Bullet list"), }); } } @@ -784,6 +824,13 @@ async fn demo( { return Err(format!("expected the facts after each run: {facts:?}").into()); } - println!("ok: asked without waiting, answered later, two reports"); + // What the reports claim was done. + if !std::path::Path::new("notes.md").exists() { + return Err("the second run did not write notes.md".into()); + } + if !facts.last().is_some_and(|f| f.contains("new: notes.md")) { + return Err(format!("the facts do not name notes.md: {facts:?}").into()); + } + println!("ok: asked without waiting, answered later, wrote notes.md, two reports"); Ok(()) } diff --git a/integrations/yoagent-frontend/plugins/pi-extensions/confirm-dangerous.ts b/integrations/yoagent-frontend/plugins/pi-extensions/confirm-dangerous.ts index da4a14c6..de0942cc 100644 --- a/integrations/yoagent-frontend/plugins/pi-extensions/confirm-dangerous.ts +++ b/integrations/yoagent-frontend/plugins/pi-extensions/confirm-dangerous.ts @@ -1,22 +1,110 @@ -// A pi extension in the style of pi's own permission gate: a dangerous bash -// command or a write to a protected path asks the user first. In pi's print -// mode (no UI) it blocks them; with a frontend attached through +// A pi extension in the style of pi's own permission gate: anything that +// could destroy or overwrite what the owner already has asks first. In pi's +// print mode (no UI) it blocks; with a frontend attached through // yoagent-frontend, the question reaches the terminal or browser. +// +// What asks: +// - shell commands that delete, discard or overwrite: `rm`, `git reset +// --hard`, `git clean`, `git checkout -- …` / `git restore`, `git branch +// -D`, a forced push, `find … -delete`, `truncate`, `shred`, `mkfs`, `dd`, +// `sudo`, recursive `chmod` / `chown`, and `mv` or a `>` redirection onto +// a file that exists; +// - a write over a file that exists, and any change to `.env` or `.git/`. +// What does not: files the agent created in this session (its deliverable, +// kept up to date) and anything in its scratch folder, when the host names +// one in `YOAGENT_SCRATCH` (the handoff agent does). Paths are compared +// with symlinks resolved (on macOS /tmp is /private/tmp). -const DANGEROUS = /\b(rm\s+-[a-z]*r|sudo|chmod\s+-R|git\s+push\s+--force|mkfs|dd\s+if=)/ +import { existsSync, realpathSync } from 'node:fs' +import { basename, dirname, isAbsolute, resolve, sep } from 'node:path' + +const ALWAYS = [ + /\bsudo\b/, + /\bgit\s+reset\s+--hard\b/, + /\bgit\s+clean\b/, + /\bgit\s+(checkout|restore)\b[^;&|]*(\s--\s|\s\.(\s|$))/, + /\bgit\s+branch\s+-D\b/, + /\bgit\s+push\b[^;&|]*\s(--force\b|-f\b)/, + /\bfind\b[^;&|]*\s-delete\b/, + /\b(truncate|shred|mkfs(\.\w+)?)\b/, + /\bdd\s+if=/, + /\bch(mod|own)\s+-R\b/, +] const PROTECTED = /(^|\/)(\.env|\.git\/)/ +/** `path` with symlinks resolved, also for a file not created yet. */ +function real(path: string): string { + try { + return realpathSync(path) + } catch { + const parent = dirname(path) + return parent === path ? path : resolve(real(parent), basename(path)) + } +} + +const SCRATCH = process.env.YOAGENT_SCRATCH ? real(resolve(process.env.YOAGENT_SCRATCH)) : undefined + +/** The words of each simple command in `command` (split on ; && || |). */ +const segments = (command: string) => + command + .split(/;|&&|\|\||\|/) + .map((part) => part.trim().split(/\s+/).filter(Boolean)) + .filter((words) => words.length > 0) + export default function (pi: any) { + // Files the agent made this session: it may overwrite or delete them. + const created = new Set() + const where = (cwd: string, file: string) => real(isAbsolute(file) ? resolve(file) : resolve(cwd, file)) + const free = (path: string) => + created.has(path) || (SCRATCH !== undefined && (path === SCRATCH || path.startsWith(SCRATCH + sep))) + // A path the agent may not touch without asking: one that exists and is not its own. + const owners = (path: string) => !free(path) && existsSync(path) + + /** Why this command needs the owner's yes, or nothing. */ + const risky = (command: string, cwd: string): boolean => { + if (ALWAYS.some((pattern) => pattern.test(command))) return true + for (const words of segments(command)) { + const program = words[0] + const args = words.slice(1).filter((word) => !word.startsWith('-')) + if (program === 'rm' || program === 'unlink' || program === 'rmdir') { + // Globs and expansions cannot be checked: ask. + if (args.length === 0 || args.some((a) => /[*?$`{]/.test(a))) return true + if (args.some((a) => !free(where(cwd, a)))) return true + } + if (program === 'mv' || program === 'cp') { + const target = args.at(-1) + if (target && owners(where(cwd, target))) return true + } + } + // `>` (not `>>`, `2>`, `>&`) onto a file that exists and is not the agent's. + for (const match of command.matchAll(/(?&\d])>(?![>&])\s*([^\s;&|<>]+)/g)) { + const path = where(cwd, match[1].replace(/^["']|["']$/g, '')) + if (owners(path)) return true + if (!existsSync(path)) created.add(path) + } + return false + } + pi.on('tool_call', async (event: any, ctx: any) => { - const subject = - event.toolName === 'bash' - ? DANGEROUS.test(String(event.input?.command ?? '')) && `Run \`${event.input.command}\`?` - : ['write', 'edit'].includes(event.toolName) && PROTECTED.test(String(event.input?.path ?? '')) - ? `Change the protected file ${event.input.path}?` - : false - if (!subject) return undefined - if (!ctx.hasUI) return { block: true, reason: 'needs confirmation, and no one is there to ask' } - const choice = await ctx.ui.select(subject, ['Allow', 'Block']) - return choice === 'Allow' ? undefined : { block: true, reason: 'the user blocked it' } + const cwd = ctx.cwd ?? process.cwd() + let subject: string | false = false + let creating: string | undefined + if (event.toolName === 'bash') { + const command = String(event.input?.command ?? '') + if (risky(command, cwd)) subject = `Run \`${command}\`?` + } else if (['write', 'edit'].includes(event.toolName)) { + const file = String(event.input?.path ?? '') + const path = where(cwd, file) + if (PROTECTED.test(file)) subject = `Change the protected file ${file}?` + else if (event.toolName === 'write' && owners(path)) subject = `Overwrite the existing file ${file}?` + else if (event.toolName === 'write' && !existsSync(path)) creating = path + } + if (subject) { + if (!ctx.hasUI) return { block: true, reason: 'needs confirmation, and no one is there to ask' } + const choice = await ctx.ui.select(subject, ['Allow', 'Block']) + if (choice !== 'Allow') return { block: true, reason: 'the user blocked it' } + } + if (creating) created.add(creating) + return undefined }) } diff --git a/integrations/yoagent-frontend/src/session.rs b/integrations/yoagent-frontend/src/session.rs index b6dad58a..88ff4f1d 100644 --- a/integrations/yoagent-frontend/src/session.rs +++ b/integrations/yoagent-frontend/src/session.rs @@ -6,7 +6,7 @@ //! channel — in order, none dropped — so `RunEnd` arrives even when a slow //! frontend fell behind on streamed text. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -45,6 +45,9 @@ struct UiState { keys: HashMap, /// Keys withdrawn before their question was asked. early: HashMap, + /// Questions asked with [`Session::ask_later`]: they belong to the + /// conversation, so a reset withdraws them. + held: HashSet, } struct Inner { @@ -234,16 +237,21 @@ impl Session { timeout: Duration, key: Option, ) -> Json { - self.ask_inner(request, Some(timeout), key, false).await + let default = request.default_answer(); + self.ask_inner(request, Some(timeout), key, false) + .await + .unwrap_or(default) } /// Ask, and keep the question open until it is answered — however long /// that takes, and with no frontend attached: one that connects later /// gets it in its `Hello`. For an agent working while its owner is away - /// (see the `handoff_agent` example). Ends with the default only when - /// the future is dropped or the session ends. A `notify` is shown and - /// returns at once. - pub async fn ask_later(&self, request: UiRequest) -> Json { + /// (see the `handoff_agent` example). `Some(answer)` when a frontend + /// answered (`null` when the user dismissed it); `None` when nobody + /// will: the question was withdrawn — a reset withdraws these, since + /// they belong to the forgotten conversation — or the session ended. A + /// `notify` is shown and returns `Some(null)` at once. + pub async fn ask_later(&self, request: UiRequest) -> Option { self.ask_inner(request, None, None, true).await } @@ -253,9 +261,8 @@ impl Session { timeout: Option, key: Option, hold: bool, - ) -> Json { + ) -> Option { let request = request.normalized(); - let default = request.default_answer(); let expects = request.expects_answer(); let (tx, rx) = oneshot::channel(); // Before the `ui` lock: `connect` takes `clients` then `ui`, so @@ -267,13 +274,13 @@ impl Session { if let Some(key) = &key { if ui.early.remove(key).is_some() { tracing::debug!(%key, "a question withdrawn before it was asked"); - return default; + return None; } } // Checked under `ui`: the session's end clears `pending` under it // too, so a question is either refused here or cleared there. if self.is_closed() { - return default; + return None; } if !attached && !hold { drop(ui); @@ -281,13 +288,16 @@ impl Session { title = request.title(), "no frontend to ask: the safe default" ); - return default; + return None; } ui.next_id += 1; let id = ui.next_id; let mut owned_key = None; if expects { ui.pending.insert(id, (tx, request.clone())); + if hold { + ui.held.insert(id); + } if let Some(key) = key { if ui.keys.contains_key(&key) { tracing::warn!(%key, "a question key already in use: this one cannot be withdrawn by key"); @@ -302,7 +312,7 @@ impl Session { let title = request.title().to_owned(); self.broadcast(ServerMessage::UiRequest { id, request }); if !expects { - return Json::Null; + return Some(Json::Null); } /// Closes the question however `ask` ends — answered, timed out, /// withdrawn by key, or dropped — and tells the frontends why. @@ -317,6 +327,7 @@ impl Session { { let mut ui = self.session.inner.ui.lock().unwrap(); ui.pending.remove(&self.id); + ui.held.remove(&self.id); if let Some(key) = &self.key { if ui.keys.get(key) == Some(&self.id) { ui.keys.remove(key); @@ -342,13 +353,13 @@ impl Session { match answered { Ok(Ok(value)) => { close.reason = ResolveReason::Answered; - value + Some(value) } - Ok(Err(_)) => default, + Ok(Err(_)) => None, Err(_) => { close.reason = ResolveReason::TimedOut; tracing::info!(%title, "no answer in time: the safe default"); - default + None } } } @@ -428,6 +439,17 @@ impl Session { } impl Inner { + /// Withdraw every question asked with [`Session::ask_later`] (a reset). + fn withdraw_held(&self) { + let mut ui = self.ui.lock().unwrap(); + let held: Vec = ui.held.drain().collect(); + for id in held { + // Dropping the waiter ends its `ask_later` with `None`; its guard + // tells the frontends. + ui.pending.remove(&id); + } + } + fn broadcast(&self, message: ServerMessage) { let mut clients = self.clients.lock().unwrap(); // Kept for later frontends: what a run showed. Questions are not @@ -500,6 +522,7 @@ impl Driver { ) => text, Some(ClientMessage::Reset) => { agent.clear_messages(); + inner.withdraw_held(); continue; } Some(ClientMessage::Quit) if self.accept_quit => break, @@ -578,6 +601,7 @@ impl Driver { agent.finish().await; if reset { agent.clear_messages(); + inner.withdraw_held(); agent.clear_steering_queue(); agent.clear_follow_up_queue(); } else if !aborted { diff --git a/integrations/yoagent-frontend/tests/session_test.rs b/integrations/yoagent-frontend/tests/session_test.rs index bfd31c24..3ea54eda 100644 --- a/integrations/yoagent-frontend/tests/session_test.rs +++ b/integrations/yoagent-frontend/tests/session_test.rs @@ -719,7 +719,7 @@ async fn a_question_asked_later_waits_for_a_frontend() { id, value: serde_json::json!(true), })); - assert_eq!(asking.await.unwrap(), serde_json::json!(true)); + assert_eq!(asking.await.unwrap(), Some(serde_json::json!(true))); assert_eq!(resolved(&mut phone).await, (id, ResolveReason::Answered)); } @@ -738,14 +738,15 @@ async fn a_question_asked_later_ends_with_the_session() { .await .expect("the question ends with the session") .unwrap(); - assert_eq!(answer, serde_json::json!(false)); + // Nobody will answer: None, not a made-up default. + assert_eq!(answer, None); let late = tokio::time::timeout( Duration::from_secs(5), session.ask_later(confirm("Another?")), ) .await .expect("refused at once on an ended session"); - assert_eq!(late, serde_json::json!(false)); + assert_eq!(late, None); } /// Holds the run's `finish` until released: a window after the loop's last @@ -820,3 +821,37 @@ async fn a_steer_arriving_as_a_run_ends_starts_the_next_run() { session.send(ClientMessage::Quit); finished(run).await; } + +/// A reset forgets the conversation, so the questions held for its owner +/// are withdrawn: the asker gets `None` (no answer to act on) and frontends +/// close them. A question asked with a timeout is not affected. +#[tokio::test] +async fn a_reset_withdraws_the_questions_held_for_the_owner() { + let (session, driver) = Session::new(true); + let mut ui = session.connect(); + next(&mut ui).await; + let run = tokio::spawn(driver.run(agent(vec![]))); + let held = { + let session = session.clone(); + tokio::spawn(async move { session.ask_later(confirm("Keep the old loader?")).await }) + }; + let held_id = asked(&mut ui).await; + let timed = ask( + &session, + confirm("Delete build?"), + Duration::from_secs(60), + None, + ); + let timed_id = asked(&mut ui).await; + session.send(ClientMessage::Reset); + assert_eq!(held.await.unwrap(), None, "withdrawn: nothing to act on"); + assert_eq!(resolved(&mut ui).await, (held_id, ResolveReason::Withdrawn)); + // The other question is still open, and answerable. + session.send(ClientMessage::UiResponse { + id: timed_id, + value: json!(true), + }); + assert_eq!(timed.await.unwrap(), json!(true)); + session.send(ClientMessage::Quit); + finished(run).await; +}