Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to `yoagent` are documented here. The format loosely
follows [Keep a Changelog](https://keepachangelog.com/), and the project
adheres to [Semantic Versioning](https://semver.org/).

## yoagent-rutis Unreleased

yoagent-rutis only; requires yoagent 0.25, rutis 0.12 and Rust 1.88, as 0.7.1. A minor bump: new values and settings for the pi adapter's grants.

- **pi adapter: per-extension grants a host can ask about.** Asked for by yobuild (its M3b plugin grants).
- `exec` / `execFor[file]` take `"gate"`: each `pi.exec` call goes to the host's `host_approval.judge([command, ...args], cwd?)` (`options.cwd` resolved against the adapter's `cwd` — and run there, so what runs is what was judged; left out without one, and the call then runs in the adapter's `cwd`). `{free: true}` runs it; otherwise `host_approval.ask(question, details | null, file)` with the judge's question and details, and only `"allow"` runs it. Any other answer, a host error, a cancel or no service is the usual refusal (code 126, reason in `stderr`, warned once per extension, command and kind of refusal; a cancel only at debug). What runs is exactly what was judged: the command, arguments and directory are copied when the call is made (an extension changing its array while you are asked changes nothing), and an `options.cwd` that is not a path string is refused unjudged. `judge` is bounded at 30 s; the question is the host's to time out, and a cancel does not withdraw it (an answer after the cancel is only logged).
- `projectTrustedFor: {file: boolean}` over `projectTrusted`: what `ctx.isProjectTrusted()` tells that extension's handlers and tools.
- `wakeFor: {file: boolean}` (default none): that extension's `pi.sendUserMessage(content)`, and `pi.sendMessage` with `triggerTurn` or `deliverAs`, become `host_session.wake(text, file)` (text blocks joined by line breaks, other blocks named; one at a time in the order sent, each after the previous answered or timed out; the message is still recorded). pi's calls return nothing, so a refusal is a `warn` line (once per extension and kind — no service, timeout, host error — repeats at debug), not an error the extension sees; a wake with no answer in 30 s is reported as such (it may still arrive) and later wakes go on. As in pi, a wake from an extension's factory (while extensions load) throws, and so does one once the adapter starts unloading (`session_shutdown` handlers included).
- Keys of every per-file setting resolve like `extensions`; one naming no loaded extension, or two spellings of one file, refuses the load. `ask` and `wake` name the extension's resolved file last (`judge` gets the argv and cwd only).
- The services reach the adapter through two new rows, `plugins/pi/host-approval.ts` and `plugins/pi/host-session.ts`, which re-provide `host_approval` / `host_session` as `pi-host-approval` / `pi-host-session` (as `host-ui.ts` does `ui`): the adapter looks them up per call, so rows without these grants load without them.
- Fixed-answer log lines name the asking extension: `[pi] fixed answer (<file>): <question> → <answer>`, once per extension, question and answer.

## 0.25.11 (2026-10-20)

A fix; no API change.
Expand Down
2 changes: 1 addition & 1 deletion integrations/yoagent-rutis/CLAUDE.md

Large diffs are not rendered by default.

22 changes: 13 additions & 9 deletions integrations/yoagent-rutis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,10 @@ onto one handler, following pi 1.1.0's own runner and agent loop:
|---|---|
| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false, in the `setActiveTools` allowlist if one was set; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` runs only with the arguments the policies left — a call another handler rewrote afterwards is not run. A throw or `isError` is an error result; text and image blocks pass through (pi's shape is yoagent's) |
| `ctx.executeTool` | another pi tool, the way pi runs a nested call: prepared, validated, judged by `tool_call` (with `parentToolCallId`), its result through `tool_result`; never rejects (an unknown tool, a block or a throw is `isError`). yoagent's own tools are not reachable from it; `ctx.tools` lists the callable ones. The host narrows it with `nestedCalls` (`own-extension`, `off`) |
| `pi.exec` | **closed by default**: a command runs only when the host's `exec` setting allows it (`true`, or a list of commands matched exactly; per extension with `execFor` — `"git"` allows `git`, not `/usr/bin/git` or `./git`). Only the command is checked: its arguments and `cwd` are the extension's, so allowing a command that can run others (`git`, `sh`, `node`, `npx`) allows those too. A refused call resolves as a failed command (code 126, empty `stdout`, the reason in `stderr`; never a throw, as pi's own `exec` resolves a failed command), so an extension that ignores the exit code reads nothing; it is a `warn` line in the host's log naming the extension file, once per extension and command. This gates pi's API only: an extension may start processes itself. Every load (whatever `exec` is) imports each extension through a small wrapper module the adapter writes to a per-user directory under the temp directory (`yoagent-pi-wrappers-<uid>`, kept and reused; refused unless it is a real directory owned by that user that no one else can write — on Windows, only that it is a directory) |
| `pi.exec` | **closed by default**: a command runs only when the host's `exec` setting allows it (`true`, or a list of commands matched exactly; per extension with `execFor` — `"git"` allows `git`, not `/usr/bin/git` or `./git`), or, with `"gate"`, when the host approves the call: `host_approval.judge([command, ...args], cwd?)` (`options.cwd` resolved against the adapter's `cwd`, which is also where it then runs; left out when the extension passed none, and the call then runs in the adapter's `cwd`) answering `{free: true}` runs it; otherwise `host_approval.ask(question, details \| null, file)` — the judge's question and details, the extension's file — and only `"allow"` runs it (any other answer, a host error, a cancel, or no service: refused). What runs is what was judged: command, arguments and directory are copied when the call is made, and an `options.cwd` that is not a path string is refused. A cancel does not withdraw the host's question. The host's `host_approval` reaches the adapter through a `plugins/pi/host-approval.ts` row (it re-provides the service as `pi-host-approval`, as `host-ui.ts` does `ui`). Otherwise only the command is checked: its arguments and `cwd` are the extension's, so allowing a command that can run others (`git`, `sh`, `node`, `npx`) allows those too. A refused call resolves as a failed command (code 126, empty `stdout`, the reason in `stderr`; never a throw, as pi's own `exec` resolves a failed command), so an extension that ignores the exit code reads nothing; it is a `warn` line in the host's log naming the extension file, once per extension, command and kind of refusal (a cancel only at debug). This gates pi's API only: an extension may start processes itself. Every load (whatever `exec` is) imports each extension through a small wrapper module the adapter writes to a per-user directory under the temp directory (`yoagent-pi-wrappers-<uid>`, kept and reused; refused unless it is a real directory owned by that user that no one else can write — on Windows, only that it is a directory) |
| `ctx.model`, `ctx.modelRegistry` | the host's: `model` (`{provider, id, name?, contextWindow?, maxTokens?, reasoning?, input?}`; default the model id of the run's latest request under provider `yoagent` (an observer event carries its run's, `agent_end` included) — before a run's first request (`input`) or outside a run (`session_start`), any run's latest request; `undefined` before the first request — in pi's `Model` shape (`api` `yoagent`, no costs, no credentials), and a **read-only** registry over `models` (default `[model]`, so empty without `model`): `getAll`, `getAvailable`, `find`, `getProviderDisplayName`, `getRegisteredProviderIds`, the `*OfType` lookups (every model is a chat model), `refresh` (does nothing); `hasConfiguredAuth` / `isUsingOAuth` false; it cannot be written to; every other method — credentials, model calls, registering providers — throws "not available in yoagent" (logged once). Both logged as fixed answers |
| `ctx.isProjectTrusted()`, `ctx.getContextUsage()`, print-mode `ctx.ui` dialogs | fixed answers: `projectTrusted` (default `false`; pi's own default is `true`), `undefined`, and no user to ask (`confirm` false, the others nothing). Each is an `info` line in the host's log the first time it is asked (once per question and answer in a runtime), tagged `[pi] fixed answer: <question> → <answer>`, so a plugin that does nothing can be traced to what it was told |
| `ctx.isProjectTrusted()`, `ctx.getContextUsage()`, print-mode `ctx.ui` dialogs | fixed answers: `projectTrusted` (default `false`; pi's own default is `true`; per extension file with `projectTrustedFor`, for its handlers and tools), `undefined`, and no user to ask (`confirm` false, the others nothing). Each is an `info` line in the host's log the first time an extension asks it (once per extension, question and answer in a runtime), tagged `[pi] fixed answer (<extension file>): <question> → <answer>`, so a plugin that does nothing can be traced to what it was told |
| `pi.sendUserMessage`, `pi.sendMessage` with `triggerTurn` or `deliverAs` | for an extension granted `wakeFor`: `host_session.wake(text, file)` — text blocks joined by line breaks, other blocks named (`[image block]`), the extension's file last — through a `plugins/pi/host-session.ts` row (re-provided as `pi-host-session`); wakes are sent one at a time in the order sent, each after the previous answered or went 30 s without an answer. pi's calls return nothing, so a refusal (a host error, no answer in 30 s, or no service) cannot reach the extension: a `warn` line, once per extension and kind. As in pi, a wake from an extension's factory throws (runtime actions are not live while extensions load), and so does one once the adapter starts unloading. A `sendMessage` is still recorded (below). Without the grant, `sendUserMessage` throws "not available in yoagent" and `sendMessage` is recorded only |
| `pi.setActiveTools` | an allowlist over the tools pi knows — yoagent's built-ins under pi's names and the extensions' tools: those outside it are not offered and their calls are denied. Tools pi does not know (MCP tools, sub-agents, the host's own) are not affected, as in pi. As in pi, a tool named in it is activated even when `defaultActive` is false (not when hidden), and a tool registered later that pi would activate joins it. `getActiveTools` / `getAllTools` answer from the same view |
| `on("tool_call")` | `before_tool` for every call. yoagent's built-ins are judged under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob` / `ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse), `list_files`→`find`; config `toolNames` adds more), with a relative `path` resolved against `cwd` first so the tool acts where the policy looked (yoagent's built-ins only: another tool's `path` may be a repository's). `{ block }` denies (`terminate: true` also stops the run at its next model request — stricter than pi, which ends only a batch whose results all set it); in-place changes to `event.input` rewrite the arguments, and one the yoagent tool cannot take (a second edit, a `timeout` on `bash`) denies the call; a throwing handler blocks, as in pi |
| `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps its text and image blocks). A throwing handler **withholds the result** — unlike pi, which skips it: a failed redaction must not let the raw output through |
Expand Down Expand Up @@ -590,8 +591,8 @@ adapter's in-memory session — readable through `ctx.sessionManager`, a
displayed message also logged — so a tool that records or shows its result
this way (pi video tools do, after the paid work) does not fail; a message
never starts a turn or reaches the model (yoagent's history is written only
by its host). The session lives as long as the adapter: in a long-running
host it grows until the plugin reloads. Other runtime actions (`pi.sendUserMessage`, `pi.setModel`,
by its host), unless the extension is granted `wakeFor` (above). The session lives as long as the adapter: in a long-running
host it grows until the plugin reloads. Other runtime actions (`pi.sendUserMessage` without `wakeFor`, `pi.setModel`,
...) throw "not available in yoagent". There is no UI: `ctx.hasUI` is false and
`ctx.ui` behaves as in pi's print mode (`confirm` answers false), so a
policy that would ask the user denies. Commands, dialogs and session
Expand All @@ -615,10 +616,13 @@ Config: `extensions` (files, or directories with an `index.ts` /
`index.js`, loaded in order; relative paths resolve against `cwd`), `cwd`
(the project the extensions see; default the runtime's), `name`,
`toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`, `exec` (default
`false`; see `pi.exec` above), `execFor` (per extension file, as in
`extensions` — resolved against `cwd` — its own grant over `exec`; a key
naming no loaded extension refuses the load), `projectTrusted` (default
`false`), `model` / `models` (see `ctx.model` above), `nestedCalls`
`false`; `"gate"` asks the host; see `pi.exec` above), `execFor` (per
extension file, as in `extensions` — resolved against `cwd` — its own grant
over `exec`; a key naming no loaded extension refuses the load, as for the
other per-file settings), `projectTrusted` (default `false`) and
`projectTrustedFor` (per extension file), `wakeFor` (per extension file,
default none; see `pi.sendUserMessage` above), `model` / `models` (see
`ctx.model` above), `nestedCalls`
(`"any"` by default, as pi; `"own-extension"`: a pi tool's `ctx.executeTool`
reaches only tools whose winning registration (the first in the list) is
its own extension's; `"off"`; a refused call is an error result to the
Expand Down Expand Up @@ -703,7 +707,7 @@ tool wins, with a warning) rather than refusing (4).
6. **Keep plugin state local, don't fail work.** A plugin that records its
own state (a session entry, a result card) gets an adapter-local store
rather than an error, so a tool does not fail after its work is done;
what would inject input or start a turn still throws.
what would inject input or start a turn still throws, unless the host grants it (`wakeFor`).
7. **No UI means "no".** With no one to ask, a dialog answers as a
headless run does — a confirmation is declined — so a policy that would
ask denies.
Expand Down
19 changes: 19 additions & 0 deletions integrations/yoagent-rutis/plugins/pi/host-approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Load next to the pi extensions adapter, in the same Node runtime, when the
// host provides a `host_approval` service and grants an extension `exec:
// "gate"` (or `execFor[file] = "gate"`): each `pi.exec` call is then judged
// by the host (`judge(argv, cwd?)`, `ask(question, details | null, file)`).
//
// rutis shows a host service only to plugins that inject it, and starts a
// plugin only once everything it injects exists, so the adapter cannot
// declare it (hosts without one would never start it): this plugin injects
// it and re-provides it as `pi-host-approval`, which the adapter looks up
// when a gated call is made. As with `host-ui.ts`, load it in one runtime only.

import { definePlugin } from '@arcships/rutis'

export default definePlugin({
inject: ['host_approval'],
apply(ctx) {
ctx.effect(ctx.provide('pi-host-approval', ctx.use('host_approval')))
},
})
19 changes: 19 additions & 0 deletions integrations/yoagent-rutis/plugins/pi/host-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Load next to the pi extensions adapter, in the same Node runtime, when the
// host provides a `host_session` service and grants extensions `wakeFor`: their
// `pi.sendUserMessage`, and `pi.sendMessage` with `triggerTurn` or `deliverAs`,
// then reach the host as `wake(text, file)`.
//
// rutis shows a host service only to plugins that inject it, and starts a
// plugin only once everything it injects exists, so the adapter cannot
// declare it (hosts without one would never start it): this plugin injects
// it and re-provides it as `pi-host-session`, which the adapter looks up when
// an extension wakes the session. As with `host-ui.ts`, load it in one runtime only.

import { definePlugin } from '@arcships/rutis'

export default definePlugin({
inject: ['host_session'],
apply(ctx) {
ctx.effect(ctx.provide('pi-host-session', ctx.use('host_session')))
},
})
Loading
Loading