feat(p2): the terminal tool needs a turn the owner started

This commit is contained in:
Mario Zechner 2026-11-05 00:03:50 +00:00
parent 5c6ce2b32f
commit 2b78884e3a
3 changed files with 16 additions and 9 deletions

View file

@ -888,8 +888,9 @@ translated phone drawer and intercept chat taps.
viewer or the agent); a panel showing a terminal keeps it, one showing none attaches the newest new one. Archiving a viewer or the agent); a panel showing a terminal keeps it, one showing none attaches the newest new one. Archiving a
session closes its terminals (`SessionArchived`, a host-lifetime contribution; no host is started for it). session closes its terminals (`SessionArchived`, a host-lifetime contribution; no host is started for it).
**Agent terminal tool** (owner decisions): the agent drives the same terminals the owner sees (shared: the owner **Agent terminal tool** (owner decisions): the agent drives the same terminals the owner sees (shared: the owner
watches and types; the agent may read, type into and close any terminal of its session, the owner's too). Guest watches and types; the agent may read, type into and close any terminal of its session, the owner's too). Only
turns may use it, like bash. Code mode only, like MCP tools (`deferred`, `callers: ["tools"]`; scripts find it with turns the owner started may use it (`origin: "owner"`, owner decision): a terminal may be connected beyond the
session's environment (ssh, a waiting sudo prompt), where a guest's bash would not reach. Code mode only, like MCP tools (`deferred`, `callers: ["tools"]`; scripts find it with
`searchTools()`); while code mode is off `tool_search` loads it (installed once by `durableRegistry`, not by each `searchTools()`); while code mode is off `tool_search` loads it (installed once by `durableRegistry`, not by each
plugin: two plugins contributing `tool-search` would be a registry conflict). The description says to use it only plugin: two plugins contributing `tool-search` would be a registry conflict). The description says to use it only
when the user asks for a terminal or for driving an interactive program in one, never instead of bash; no prompt when the user asks for a terminal or for driving an interactive program in one, never instead of bash; no prompt
@ -1065,7 +1066,8 @@ translated phone drawer and intercept chat taps.
`origin` (`"any"` default, or `"owner"`: only a turn the owner started) and `scope` (`"any"` default, `"main"`: only `origin` (`"any"` default, or `"owner"`: only a turn the owner started) and `scope` (`"any"` default, `"main"`: only
top-level conversations, or `"subagent"`: given to subagents only). The worker's Durable registry wraps every tool top-level conversations, or `"subagent"`: given to subagents only). The worker's Durable registry wraps every tool
with requirements (`guardTool`, in `durableRegistry`), so no tool checks them itself. `origin: "owner"`: the location with requirements (`guardTool`, in `durableRegistry`), so no tool checks them itself. `origin: "owner"`: the location
tools, `reload_plugins`, `conversations`, `ports`, MCP tools (the owner's accounts are behind them). A turn's origin tools, `reload_plugins`, `conversations`, `ports`, MCP tools (the owner's accounts are behind them), `terminal`
(the owner's terminals, possibly connected beyond the session's environment). A turn's origin
(`originOf`, `src/runtime/origin.ts`) is the conversation's current run (`pi.live.run.inputs`, each submission's (`originOf`, `src/runtime/origin.ts`) is the conversation's current run (`pi.live.run.inputs`, each submission's
entry through `tx.submission`): the owner's only if every input the run placed is (`entryOrigin`): a person's input entry through `tx.submission`): the owner's only if every input the run placed is (`entryOrigin`): a person's input
by its author in the entry data, an agent-written one by its `origin`. Agent-written inputs record the writing by its author in the entry data, an agent-written one by its `origin`. Agent-written inputs record the writing

View file

@ -1,17 +1,19 @@
// The agent's `terminal` tool (worker): tmux commands as argv on the session's terminals, the ones the owner sees in // The agent's `terminal` tool (worker): tmux commands as argv on the session's terminals, the ones the owner sees in
// the terminal panel. Code mode scripts call it (`tools.terminal({ command, args })`); without code mode `tool_search` // the terminal panel. Code mode scripts call it (`tools.terminal({ command, args })`); without code mode `tool_search`
// loads it, as MCP tools. Replay-safe: the host does a call with the tool call's key once, and reads start where the // loads it, as MCP tools. Only turns the owner started may call it (`origin: "owner"`): the terminals are the owner's
// first attempt's did (memoized), so a call a restart interrupted runs again without typing or opening twice. // too, possibly connected where the session's `bash` would not reach (an ssh session, a waiting sudo prompt).
// Replay-safe: the host does a call with the tool call's key once, and reads start where the first attempt's did
// (memoized), so a call a restart interrupted runs again without typing or opening twice.
import { import {
defineExtension, defineExtension,
defineTool,
type Extension, type Extension,
type ToolExecutionApi, type ToolExecutionApi,
type ToolExecutionResult, type ToolExecutionResult,
} from "@earendil-works/pi-durable"; } from "@earendil-works/pi-durable";
import { arr, bool, int, obj, opt, type Static, str, union } from "@earendil-works/pi-schema"; import { arr, bool, int, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
import { type Context, isRooted, joinPath, type Location, literal } from "@p2/app/v1"; import { type Context, isRooted, joinPath, type Location, literal } from "@p2/app/v1";
import { defineP2Tool, type P2Tool } from "@p2/app/v1/worker";
import { import {
COMMANDS, COMMANDS,
CommandError, CommandError,
@ -99,11 +101,11 @@ export function createTerminalExtension(
reach: TerminalReach, reach: TerminalReach,
backend: TerminalBackend, backend: TerminalBackend,
locate: (api: ToolExecutionApi, context: Context) => Promise<Location>, locate: (api: ToolExecutionApi, context: Context) => Promise<Location>,
): Extension { ): Extension<P2Tool> {
return defineExtension({ return defineExtension<P2Tool>({
name: TERMINAL_EXTENSION, name: TERMINAL_EXTENSION,
tools: [ tools: [
defineTool({ defineP2Tool({
name: "terminal", name: "terminal",
description: DESCRIPTION, description: DESCRIPTION,
parameters: obj({ parameters: obj({
@ -116,6 +118,7 @@ export function createTerminalExtension(
...(reach === "codemode" ? { callers: ["tools" as const] } : {}), ...(reach === "codemode" ? { callers: ["tools" as const] } : {}),
outputLimits: { maxBytes: OUTPUT_CHARS + 1024, retain: "tail" }, outputLimits: { maxBytes: OUTPUT_CHARS + 1024, retain: "tail" },
replay: "safe", replay: "safe",
origin: "owner",
execute: async (args, api, context) => { execute: async (args, api, context) => {
let command: TerminalCommand; let command: TerminalCommand;
try { try {

View file

@ -108,6 +108,8 @@ test("code mode only while code mode is on; tool_search reaches it otherwise", (
assert.deepEqual(terminal(backend).callers, ["tools"]); assert.deepEqual(terminal(backend).callers, ["tools"]);
assert.equal(terminal(backend).deferred, true); assert.equal(terminal(backend).deferred, true);
assert.equal(terminal(backend).replay, "safe"); assert.equal(terminal(backend).replay, "safe");
// The owner's terminals: only turns the owner started (checked by the worker's registry).
assert.equal(terminal(backend).origin, "owner");
assert.equal(terminal(backend, "tool_search").callers, undefined); assert.equal(terminal(backend, "tool_search").callers, undefined);
assert.equal(terminal(backend, "tool_search").deferred, true); assert.equal(terminal(backend, "tool_search").deferred, true);
assert.match(terminal(backend).description, /Only when the user asks for a terminal/); assert.match(terminal(backend).description, /Only when the user asks for a terminal/);