From 2b78884e3ad92716b77f03b55815dce803770470 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 5 Nov 2026 00:03:50 +0000 Subject: [PATCH] feat(p2): the terminal tool needs a turn the owner started --- apps/p2/docs/p2-notes.md | 8 +++++--- apps/p2/plugins/terminals/tool.ts | 15 +++++++++------ apps/p2/test/p2/terminal-tool-unit.test.ts | 2 ++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index 09e4ce860..575fd6810 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -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 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 - watches and types; the agent may read, type into and close any terminal of its session, the owner's too). Guest - turns may use it, like bash. Code mode only, like MCP tools (`deferred`, `callers: ["tools"]`; scripts find it with + watches and types; the agent may read, type into and close any terminal of its session, the owner's too). Only + 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 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 @@ -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 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 - 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 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 diff --git a/apps/p2/plugins/terminals/tool.ts b/apps/p2/plugins/terminals/tool.ts index a9a2d2583..54f99a967 100644 --- a/apps/p2/plugins/terminals/tool.ts +++ b/apps/p2/plugins/terminals/tool.ts @@ -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 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 -// first attempt's did (memoized), so a call a restart interrupted runs again without typing or opening twice. +// loads it, as MCP tools. Only turns the owner started may call it (`origin: "owner"`): the terminals are the owner's +// 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 { defineExtension, - defineTool, type Extension, type ToolExecutionApi, type ToolExecutionResult, } from "@earendil-works/pi-durable"; 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 { defineP2Tool, type P2Tool } from "@p2/app/v1/worker"; import { COMMANDS, CommandError, @@ -99,11 +101,11 @@ export function createTerminalExtension( reach: TerminalReach, backend: TerminalBackend, locate: (api: ToolExecutionApi, context: Context) => Promise, -): Extension { - return defineExtension({ +): Extension { + return defineExtension({ name: TERMINAL_EXTENSION, tools: [ - defineTool({ + defineP2Tool({ name: "terminal", description: DESCRIPTION, parameters: obj({ @@ -116,6 +118,7 @@ export function createTerminalExtension( ...(reach === "codemode" ? { callers: ["tools" as const] } : {}), outputLimits: { maxBytes: OUTPUT_CHARS + 1024, retain: "tail" }, replay: "safe", + origin: "owner", execute: async (args, api, context) => { let command: TerminalCommand; try { diff --git a/apps/p2/test/p2/terminal-tool-unit.test.ts b/apps/p2/test/p2/terminal-tool-unit.test.ts index 1b6d4753f..9f69d3258 100644 --- a/apps/p2/test/p2/terminal-tool-unit.test.ts +++ b/apps/p2/test/p2/terminal-tool-unit.test.ts @@ -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.equal(terminal(backend).deferred, true); 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").deferred, true); assert.match(terminal(backend).description, /Only when the user asks for a terminal/);