feat(p2): subagents can use code mode

codemode is choosable for subagents and in their default tools while the
codemode setting is on; one-shot agents never get it. A subagent with code
mode gets the script-only tools (MCP, terminal, agent, models.*) by name in
its Durable tools array, at spawn/configure and from the worker when the
registry's script-only tools change.
This commit is contained in:
Armin Ronacher 2026-11-04 21:53:05 +01:00
parent 617d0ef549
commit 474416d70b
7 changed files with 317 additions and 24 deletions

View file

@ -894,7 +894,7 @@ translated phone drawer and intercept chat taps.
section. Call: `tools.terminal({ command, args })`, tmux's argv after the command name, parsed getopt-style
(`commands.ts`: no string parsing, tmux-like errors such as `send-keys: unknown option -- x` or `usage: …`):
`new-window [-n name] [-c dir] [-w W] [-T s] [command ...]` (a shell at the calling conversation's location,
`environments.locate`; subagents cannot choose code mode or this tool yet; `-c` relative to it; `command` is typed into it, then Enter, and the shell stays),
`environments.locate`: a subagent's script opens it at the subagent's own location; `-c` relative to it; `command` is typed into it, then Enter, and the shell stays),
`list-windows`, `send-keys -t T [-l] [-w W] [-T s] key ...` (tmux key names as keys, encoded by libghostty in the
terminal's modes, other arguments as text), `read -t T [-w W] [-T s]` (output since the agent's last read),
`capture-pane -t T [-S -N] [-e] [-p]` (the screen, N scrollback lines, `-e` with SGR colors), `kill-window -t T`.
@ -1037,9 +1037,22 @@ translated phone drawer and intercept chat taps.
the list does not start/hold a worker. The sidebar expands children (latest entry first), with working dots;
`/s/<id>/c/<conversation>` opens an independent chat, draft and model control. A parent link returns to the root.
Browser configuration synchronizes the subagent registry via `ConversationConfigured`. Main Stop leaves child
work running; direct child prompts do not report to the parent. `test/p2/subagents.test.ts` covers bad models,
configured spawn, one report, answer retrieval, configuration, independent history, reload during child work,
idle restart, guest reads and sidebar persistence across server restart.
work running; direct child prompts do not report to the parent. **Tools** (`SUBAGENT_TOOLS` in `contract.worker.ts`,
validated by `resolveChoice` before any commit): `tools` may name the coding tools, `codemode` and the location tools;
the default is the coding tools and `codemode`, as the main agent has code mode while the `codemode` setting is on
(the default leaves out what is not installed: code mode while the setting is off or without the plugin; naming an
uninstalled tool is refused, "Not available in this session: codemode."). `team` and the history tools come on
top. Durable enables exactly the names in a conversation's `tools` array, and code mode's nested calls resolve among
the enabled tools, so a subagent with code mode also gets the script-only tools (`callers: ["tools"]`: MCP tools,
`terminal`, `agent`, code mode's `models.*`) by name: at spawn/configure those installed then (`withExtras`), and
later ones (an MCP server connecting) from the worker, which follows the registry's script-only tools and appends
missing names to every code mode subagent's array (`enableScriptTools`; a tool task resolves its agent once per
phase, so this cannot happen inside the script's own call). Names are never removed: Durable ignores a name whose
tool is gone. A subagent's scripts can thus start one-shot agents (which get no code mode). `test/p2/subagents.test.ts`
covers bad models, configured spawn, one report, answer retrieval, configuration, independent history, reload during
child work, idle restart, guest reads, sidebar persistence across server restart, and code mode: choosing and
validating it, the default with the setting on and off, a script calling a coding tool and `agent`, and MCP tools
installed after the spawn; `terminal-tool.test.ts` a subagent's script opening a window at its own location.
- **Code mode** (`plugins/codemode`): the worker facet registers Durable's `codemode` extension
(`createCodemodeExtension`, tool `codemode`, callers `["model"]`, replay-safe) in `Extensions`. One sandbox per worker
(`CodemodeSandbox`, 256 MiB VM heap: `CODEMODE_MEMORY_LIMIT_BYTES`, kept constant because it is part of the VM
@ -1121,7 +1134,8 @@ translated phone drawer and intercept chat taps.
`structuredOutput` is the value, `details` `{ conversationId, attempts }`; nothing is reported to the parent. It has no
prompt section (as Pi Mobile 1b763d912): its description, Pi Mobile's verbatim, carries the usage notes, and code
mode's section lists it with the other tools scripts call. Model/thinking/tool
validation is the subagent tool's `resolveChoice`. The plugin contributes the tool unconditionally and does not
validation is the subagent tool's `resolveChoice` with `ONE_SHOT_TOOLS`: the coding and location tools, never
`codemode` (a one-shot agent's own scripts could start one-shot agents again, fanning out without bound). The plugin contributes the tool unconditionally and does not
depend on code mode: without a script nothing can call it (`callers: ["tools"]`), so code mode stays removable while
it is loaded (code mode's listing then disappears; `agent.test.ts` removes and re-adds code mode). It depends on subagents,
whose document lists its conversations. The creating commit

View file

@ -18,7 +18,7 @@ import { formats } from "@earendil-works/pi-schema/formats";
import type { JsonValue } from "@p2/app/v1";
import { record } from "@p2/app/v1";
import { isTopLevel } from "@p2/app/v1/worker";
import { resolveChoice, Subagents } from "../subagents/contract.worker.ts";
import { ONE_SHOT_TOOLS, resolveChoice, Subagents } from "../subagents/contract.worker.ts";
export const AGENT_TOOL = "agent";
const ATTEMPTS = 3;
@ -72,7 +72,10 @@ export function createAgentExtension(models: Models) {
}
// A rerun reattaches to the conversation it created, even if its model has since left the configuration.
const existing = (await api.snapshot(AgentRun, api.taskId, context))?.conversationId;
const choice = existing === undefined ? await resolveChoice(models, api, args, undefined, context) : undefined;
const choice =
existing === undefined
? await resolveChoice(models, api, args, undefined, ONE_SHOT_TOOLS, context)
: undefined;
if (typeof choice === "string") return fail(choice);
// Owned by this call: aborting the call aborts the agent's work. Listed in the same commit, by the call ID the
// script's tool card shows it under, so the user can open its transcript.

View file

@ -21,8 +21,21 @@ import type { Context, Location } from "@p2/app/v1";
export const CODING_TOOLS = ["read", "write", "edit", "bash", "send_file", "artifact"];
/** The location tools (plugins/locations): every main agent has them; a subagent only when chosen. */
export const LOCATION_TOOLS = ["location_envs", "location_dirs", "location_move"];
/** What a subagent's `tools` may name: the coding tools (its default) and the location tools. */
export const CHOOSABLE = [...CODING_TOOLS, ...LOCATION_TOOLS];
/** The code mode tool (plugins/codemode), installed while the `codemode` setting is on. */
export const CODEMODE_TOOL = "codemode";
/** The tools an agent may choose (`choosable`) and gets without a choice (`defaults`), by name. */
export type ToolOffer = { readonly choosable: readonly string[]; readonly defaults: readonly string[] };
/**
* A subagent's: the coding tools and code mode by default (code mode only while installed, as for the main agent), the
* location tools when chosen.
*/
export const SUBAGENT_TOOLS: ToolOffer = {
choosable: [...CODING_TOOLS, CODEMODE_TOOL, ...LOCATION_TOOLS],
defaults: [...CODING_TOOLS, CODEMODE_TOOL],
};
/** A one-shot agent's (plugins/agent): no code mode, so a script's agents cannot run scripts that fan out again. */
export const ONE_SHOT_TOOLS: ToolOffer = { choosable: [...CODING_TOOLS, ...LOCATION_TOOLS], defaults: CODING_TOOLS };
export const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
export const TEAM_TOOL = "team";
@ -107,14 +120,16 @@ export const Subagents = defineDoc<SubagentState>({
export type AgentChoice = { model?: string; thinkingLevel?: string; tools?: string[] };
/**
* Validate a choice before any commit, so a typo changes nothing; an error is returned as text. Unset fields keep
* `current`'s, or for a new agent the caller's model and thinking level and every coding tool.
* Validate a choice before any commit, so a typo changes nothing; an error is returned as text. `offer` says which tools
* it may name. Unset fields keep `current`'s, or for a new agent the caller's model and thinking level and the offer's
* defaults that are installed. A chosen tool that is not installed (code mode while its setting is off) is an error.
*/
export async function resolveChoice(
models: Models,
api: Pick<ToolExecutionApi, "agent" | "registry">,
choice: AgentChoice,
current: Subagent | undefined,
offer: ToolOffer,
context: Context,
): Promise<{ model: ModelRef; thinkingLevel: ModelThinkingLevel; tools: ToolRegistration[] } | string> {
const own = current ? undefined : await api.agent(context);
@ -135,10 +150,13 @@ export async function resolveChoice(
current?.thinkingLevel ??
own?.thinkingLevel ??
"off") as ModelThinkingLevel;
const names = (choice.tools ?? current?.tools ?? CODING_TOOLS).filter((tool) => !ALWAYS.includes(tool));
const unknown = names.filter((tool) => !CHOOSABLE.includes(tool));
if (unknown.length) return `Unknown tools: ${unknown.join(", ")}. Choose from ${CHOOSABLE.join(", ")}.`;
const names = (choice.tools ?? current?.tools ?? offer.defaults).filter((tool) => !ALWAYS.includes(tool));
const unknown = names.filter((tool) => !offer.choosable.includes(tool));
if (unknown.length) return `Unknown tools: ${unknown.join(", ")}. Choose from ${offer.choosable.join(", ")}.`;
const installed = api.registry.tools().map((entry) => entry.tool);
const missing =
choice.tools === undefined ? [] : names.filter((tool) => !installed.some((each) => each.name === tool));
if (missing.length) return `Not available in this session: ${missing.join(", ")}.`;
return {
model: ref,
thinkingLevel: clampThinkingLevel(model, requested),

View file

@ -5,6 +5,7 @@
import type { AssistantMessage, ModelThinkingLevel } from "@earendil-works/pi-ai";
import type { Models } from "@earendil-works/pi-ai/models";
import {
AgentDoc,
AssistantEntry,
type ConversationHandle,
type ConversationId,
@ -29,7 +30,14 @@ import type { Context, JsonValue } from "@p2/app/v1";
import { type Location, type LocationInput, literal, locationText } from "@p2/app/v1";
import { mainConversation, originOf } from "@p2/app/v1/worker";
import { AGENT_MESSAGE_ENTRY, type AgentMessage, SUBAGENT_TASK_ENTRY, SubagentLocationsSchema } from "./contract.ts";
import { HISTORY_TOOLS, resolveChoice, Subagents, TEAM_TOOL } from "./contract.worker.ts";
import {
CODEMODE_TOOL,
HISTORY_TOOLS,
resolveChoice,
SUBAGENT_TOOLS,
Subagents,
TEAM_TOOL,
} from "./contract.worker.ts";
const SUBAGENT_EXTENSION = "subagent-tools";
@ -362,9 +370,10 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
" env is an environment name from location_envs, or null for this device (default: yours; another environment needs",
" the owner's request).",
" model is 'provider/modelId' (default: yours); thinkingLevel is off|minimal|low|medium|high|xhigh|max",
" (clamped to the model); tools is a subset of read, write, edit, bash, send_file, artifact (default: all of these;",
" [] for none) and the location tools location_envs, location_dirs, location_move (never by default; with",
" location_move it can move itself).",
" (clamped to the model); tools is a subset of read, write, edit, bash, send_file, artifact, codemode (default: all",
" of these, codemode while code mode is on; [] for none) and the location tools location_envs, location_dirs,",
" location_move (never by default; with location_move it can move itself). With codemode its scripts call its",
" chosen tools and the tools behind code mode (MCP tools, terminal, one-shot agents).",
"- send (name, message; followUp: true queues after the current answer instead of steering).",
"- configure (name; optional model, thinkingLevel, tools, env, cwd, followSession): replace its model/thinking/tool",
" selection for later work; env/cwd give it its own location, followSession: true makes it follow the session",
@ -488,7 +497,8 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
if (action === "spawn" || action === "configure") {
if (action === "spawn" && agent)
return reply(`${name} already exists${agent.archived ? " (archived)" : ""}; use send.`, {}, true);
const choice = await resolveChoice(models, api, args, action === "spawn" ? undefined : agent, context);
const existing = action === "spawn" ? undefined : agent;
const choice = await resolveChoice(models, api, args, existing, SUBAGENT_TOOLS, context);
if (typeof choice === "string") return reply(choice, {}, true);
change = { model: choice.model, thinkingLevel: choice.thinkingLevel };
tools = choice.tools;
@ -600,11 +610,47 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
/**
* A subagent's tools plus the ones every subagent has: `team`, which is deferred (only conversations whose `modelTools`
* adds it are offered it), and the history tools among `installed`.
* adds it are offered it), and the history tools among `installed`; with code mode also the tools behind it among
* `installed` (`scriptOnly`; the worker adds ones installed later, see `enableScriptTools`).
*/
function withExtras(tools: readonly ToolRegistration[], installed: readonly ToolRegistration[]) {
const history = HISTORY_TOOLS.flatMap((name) => installed.filter((tool) => tool.name === name).slice(0, 1));
return { tools: [...tools, teamTool, ...history], modelTools: { add: [teamTool] } };
const behind = tools.some((tool) => tool.name === CODEMODE_TOOL) ? installed.filter(scriptOnly) : [];
return { tools: [...tools, teamTool, ...history, ...behind], modelTools: { add: [teamTool] } };
}
/**
* Whether only code mode scripts call `tool` (`callers: ["tools"]`: MCP tools, `terminal`, `agent`, code mode's own
* `models.*`). Durable enables exactly the tools a conversation's `tools` array names, and nested calls resolve among
* the enabled ones, so a subagent's array must name them for its scripts to reach them.
*/
export const scriptOnly = (tool: ToolRegistration): boolean => tool.callers?.includes("model") === false;
/**
* Adds the tools behind code mode installed now (`names`) to every subagent with code mode, by name: MCP tools come and
* go with their servers, so the names written at spawn go stale. It runs between scripts, not inside one: a script's
* call resolves the agent once, at its start. A name whose tool goes away is ignored by Durable and works again when it
* returns, so none is removed.
*/
export async function enableScriptTools(
harness: Pick<Harness, "snapshot" | "commit">,
names: readonly string[],
context: Context,
): Promise<void> {
const registry = await harness.snapshot(Subagents, ROOT_CONVERSATION_ID, context);
const lacking: ConversationId[] = [];
for (const agent of Object.values(registry?.agents ?? {})) {
if (!agent.tools.includes(CODEMODE_TOOL)) continue;
const enabled = (await harness.snapshot(AgentDoc, agent.conversationId, context))?.tools;
if (Array.isArray(enabled) && names.some((name) => !enabled.includes(name))) lacking.push(agent.conversationId);
}
if (lacking.length === 0) return;
await harness.commit(async (tx) => {
for (const conversationId of lacking) {
const state = await tx.doc(AgentDoc, conversationId);
if (Array.isArray(state.tools)) state.tools = [...new Set([...state.tools, ...names])];
}
}, context);
}
/**

View file

@ -2,7 +2,7 @@ import { setTimeout as sleep } from "node:timers/promises";
import type { Models } from "@earendil-works/pi-ai/models";
import { type ConversationId, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
import { canSend, contribute, defineFacet, expectVariant, forbidden, signalOf } from "@p2/app/v1";
import { durableContext } from "@p2/app/v1/node";
import { durableContext, ExtensionSwitches } from "@p2/app/v1/node";
import {
CommitSignal,
ConversationConfigured,
@ -21,8 +21,10 @@ import { type Child, SubagentControl, Subagents } from "./contract.ts";
import { ALWAYS, Subagents as Registry } from "./contract.worker.ts";
import {
createSubagentExtension,
enableScriptTools,
ownLocation,
placeSubagents,
scriptOnly,
setArchived,
spawnEnvironments,
teamExtension,
@ -60,6 +62,18 @@ export default defineFacet((host, self) => {
directory: (target, from, context) => resolveDirectory(harness().environments, target, from, context),
});
self.contribute(Extensions, { [extension.name]: extension, [teamExtension.name]: teamExtension });
// The tools behind code mode, as the Harness's registry installs them (switched-off extensions left out).
const scriptTools = self.derive(
[self.registry(Extensions), self.registry(ExtensionSwitches)],
([extensions, switches]) =>
[...extensions]
.filter(([name]) => switches.get(name) !== false)
.flatMap(([, each]) => (each.tools ?? []).filter(scriptOnly).map((tool) => tool.name))
.sort(),
);
self.on(scriptTools, async ({ value }, ctx) => {
await enableScriptTools(harness().durable, value, durableContext(ctx));
});
self.contribute(ConversationConfigured, {
subagents: async (conversation, ctx) => {
if (conversation.id === ROOT_CONVERSATION_ID) return;

View file

@ -2,10 +2,12 @@ import assert from "node:assert/strict";
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import type { ConversationView } from "@earendil-works/pi-durable";
import { BACKGROUND_CONTEXT, whenLive } from "@p2/app/v1";
import { Chat } from "../../plugins/chat/contract.ts";
import { Mcp } from "../../plugins/mcp/contract.ts";
import { Sessions } from "../../plugins/sessions/contract.ts";
import { Sharing } from "../../plugins/sharing/contract.ts";
import { SessionStatus } from "../../plugins/status/contract.ts";
@ -15,6 +17,8 @@ import { startServer } from "../../src/hosts/server.ts";
import type { Host, RefIn } from "../../src/runtime/chord.ts";
import { expectChord, type Fixture, failureOf, holdIn, http, startFixture, until } from "./helpers.ts";
const MCP_FIXTURE = fileURLToPath(new URL("../fixtures/mcp-server.mjs", import.meta.url));
const texts = (view: ConversationView | null | undefined): string[] =>
(view?.entries ?? []).flatMap((entry) =>
(entry.model ?? []).flatMap((message) =>
@ -325,3 +329,172 @@ describe("a subagent spawned with its own cwd (fake provider)", () => {
children.release();
});
});
describe("subagents with code mode (fake provider)", () => {
let fixture: Fixture;
let owner: Host;
let chat: RefIn<typeof Chat, "participant">;
let children: RefIn<typeof Subagents, "reader">;
let session: string;
let sent = 0;
/** Sends `subagent <args>` and waits for that call's result. */
const subagent = async (args: object): Promise<ToolResultMessage> => {
const count = results(chat.view.value).length;
await chat.send(
{ text: `subagent ${JSON.stringify(args)}`, requestId: `codemode-${++sent}` },
BACKGROUND_CONTEXT,
);
return results(await until(chat.view, (view) => results(view).length > count)).at(-1)!;
};
/** Waits until `name`'s answer was reported and the main agent's turn on it ended, so it cannot race a later send. */
const reported = (name: string) =>
until(
chat.view,
(view) =>
texts(view).some((each) => each.startsWith(`Echo: [subagent ${name} answered`)) &&
!(view?.docs["pi.live"] as { run?: unknown } | undefined)?.run,
);
const results = (view: ConversationView | null | undefined) =>
(view?.entries ?? [])
.flatMap((entry) => entry.model ?? [])
.filter(
(message): message is ToolResultMessage => message.role === "toolResult" && message.toolName === "subagent",
);
const text = (message: ToolResultMessage | undefined) =>
(message?.content ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
before(async () => {
fixture = await startFixture();
await writeFile(join(fixture.workspace, "marker.txt"), "inside\n");
owner = fixture.browser();
session = (
await (await holdIn(owner, Sessions, {}, "owner")).create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT)
).id;
chat = await holdIn(owner, Chat, { session, conversation: 1 }, "participant");
children = await holdIn(owner, Subagents, {}, "reader");
});
after(async () => {
await fixture?.close();
});
it("validates codemode as a choosable tool", async () => {
const bad = await subagent({ action: "spawn", name: "bad", message: "x", tools: ["codemode", "bogus"] });
assert.equal(bad.isError, true);
assert.equal(
text(bad),
"Unknown tools: bogus. Choose from read, write, edit, bash, send_file, artifact, codemode, location_envs, location_dirs, location_move.",
);
const chosen = await subagent({ action: "spawn", name: "chosen", message: "x", tools: ["bash", "codemode"] });
assert.deepEqual((chosen.details as { tools?: string[] }).tools, ["bash", "codemode"]);
await reported("chosen");
});
it("gives code mode by default; a script calls its tools and the tools behind code mode", async () => {
// The subagent's script reads with a chosen coding tool, then starts a one-shot agent (`agent` is script-only:
// `callers: ["tools"]`), whose tools may not include code mode.
const script = [
'const read = await tools.read({ path: "marker.txt" });',
'const answer = await tools.agent({ prompt: \'json {"n":1}\', schema: { type: "object" } });',
"let refused;",
'try { await tools.agent({ prompt: "x", schema: {}, tools: ["codemode"] }); } catch (error) { refused = String(error.message ?? error); }',
"return { read, answer, refused };",
].join("\n");
const spawned = await subagent({ action: "spawn", name: "coder", message: `codemode ${script}` });
assert.equal(spawned.isError, false, text(spawned));
assert.deepEqual((spawned.details as { tools?: string[] }).tools, [
"read",
"write",
"edit",
"bash",
"send_file",
"artifact",
"codemode",
]);
const listed = await until(children.children, (value) =>
Boolean(value?.[session]?.some((each) => each.name === "coder")),
);
const coder = listed![session]!.find((each) => each.name === "coder")!;
const sub = await holdIn(owner, Chat, { session, conversation: coder.conversationId }, "participant");
const view = await until(sub.view, (value) =>
(value?.entries ?? []).some((entry) =>
entry.model?.some((message) => message.role === "toolResult" && message.toolName === "codemode"),
),
);
const ran = (view?.entries ?? [])
.flatMap((entry) => entry.model ?? [])
.find(
(message): message is ToolResultMessage => message.role === "toolResult" && message.toolName === "codemode",
);
assert.equal(ran?.isError, false, text(ran));
const output = text(ran);
assert.match(output, /^Script completed/);
const value = JSON.parse(output.slice(output.indexOf("{"))) as { read: string; answer: unknown; refused: string };
assert.match(value.read, /inside/);
assert.deepEqual(value.answer, { n: 1 });
assert.match(
value.refused,
/Unknown tools: codemode\. Choose from read, write, edit, bash, send_file, artifact, location_envs/,
);
// The script-only tools were enabled by name before the script started; the model is still offered its choice.
const tools = (sub.view.value?.docs["pi.agent"] as { tools: string[] }).tools;
assert.ok(tools.includes("agent"), tools.join());
await reported("coder");
await until(children.children, (value) =>
Boolean(value?.[session]?.find((each) => each.name === "coder")?.tools.includes("codemode")),
);
sub.release();
});
it("reaches MCP tools installed after the spawn", async () => {
// The server connects after `coder` was spawned: the worker adds its tools to the subagent's tool list.
await writeFile(
join(fixture.dataDir, "mcp.json"),
JSON.stringify({ mcpServers: { files: { command: process.execPath, args: [MCP_FIXTURE] } } }),
);
const mcp = await holdIn(owner, Mcp, {}, "owner");
await until(mcp.instances, (instances) => instances.some((item) => item.status === "connected"));
const coder = (await until(children.children, (value) => Boolean(value?.[session]?.length)))![session]!.find(
(each) => each.name === "coder",
)!;
const sub = await holdIn(owner, Chat, { session, conversation: coder.conversationId }, "participant");
const scripts = () =>
(sub.view.value?.entries ?? [])
.flatMap((entry) => entry.model ?? [])
.filter(
(message): message is ToolResultMessage =>
message.role === "toolResult" && message.toolName === "codemode",
);
const before = scripts().length;
const script = 'return await tools.mcp__files__echo({ text: "from coder" });';
await subagent({ action: "send", name: "coder", message: `codemode ${script}` });
await until(sub.view, () => scripts().length > before);
const ran = scripts().at(-1);
assert.equal(ran?.isError, false, text(ran));
assert.match(text(ran), /echo: from coder$/);
await reported("coder");
sub.release();
mcp.release();
});
it("leaves code mode out while the codemode setting is off, and refuses choosing it", async () => {
await writeFile(join(fixture.dataDir, "settings.json"), JSON.stringify({ codemode: false }));
// The worker applies the settings file asynchronously: spawn (a new name each time) until it refuses.
let refused: ToolResultMessage | undefined;
for (let attempt = 0; attempt < 20 && !refused?.isError; attempt++) {
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 200));
refused = await subagent({ action: "spawn", name: `off-${attempt}`, message: "x", tools: ["codemode"] });
if (!refused.isError) await reported(`off-${attempt}`);
}
assert.equal(refused?.isError, true);
assert.equal(text(refused), "Not available in this session: codemode.");
const plain = await subagent({ action: "spawn", name: "plain", message: "x" });
assert.deepEqual((plain.details as { tools?: string[] }).tools, [
"read",
"write",
"edit",
"bash",
"send_file",
"artifact",
]);
});
});

View file

@ -1,10 +1,11 @@
// The agent's terminal tool end to end with the fake provider and a real terminal host (skipped without a pi-env
// daemon build): code mode scripts drive the session's terminals, which are the owner's (listed, labeled, typed into
// by both); guest turns may use it; tmux errors come back as tool errors; the server serves only the session's current worker, on the session's environments, and archiving closes
// the session's terminals. Without code mode, `tool_search` loads the tool.
// the session's terminals; a subagent's scripts open windows at its own location. Without code mode, `tool_search`
// loads the tool.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { realpathSync, writeFileSync } from "node:fs";
import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "node:test";
import { setTimeout as sleep } from "node:timers/promises";
@ -210,6 +211,30 @@ test(
await expectChord(whenLive(owner.hold(AgentTerminals, {}, BACKGROUND_CONTEXT)), "forbidden");
});
await t.test("a subagent's script opens windows at the subagent's own location", async () => {
const own = join(workspace, "own");
mkdirSync(own);
const opener =
'return await tools.terminal({ command: "new-window", args: ["-n", "sub", "-w", "/^in-sub$/", "echo in-s\'\'ub"] });';
const spawn = { action: "spawn", name: "own", message: `codemode ${opener}`, cwd: "own" };
const spawned = await call(`subagent ${JSON.stringify(spawn)}`);
assert.equal(spawned.isError, false, text(spawned));
// Its answer is reported to the main conversation; that turn ends before the next script.
await until(
chat.view,
(value) =>
JSON.stringify(value?.entries ?? []).includes("[subagent own answered") &&
!(value?.docs["pi.live"] as { run?: unknown })?.run,
);
const { windows } = (await script('return await tools.terminal({ command: "list-windows" });')) as {
windows: { name?: string; opener: string; location: string }[];
};
assert.deepEqual(
windows.filter((window) => window.name === "sub").map(({ opener, location }) => ({ opener, location })),
[{ opener: "agent", location: own }],
);
});
await t.test("archiving the session closes its terminals", async () => {
await sessions.archive({ id: session.id }, BACKGROUND_CONTEXT);
const service = (await fixture.server.host.registry(TerminalServices, BACKGROUND_CONTEXT)).value!.service;