Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e2a2d1588d fix(agents): validate optional MCP requirements before plugin injection
Keep optional approval decisions in the provider registry and preserve required plugin servers. Remove requirements alongside stripped internal servers so persistence cannot retain dangling metadata.
2026-10-29 09:14:58 +01:00
Mohamed Boudra 2325a2f32d test(agents): cover optional MCP through registered provider contracts 2026-10-29 09:04:20 +01:00
Mohamed Boudra 12a33ea21a fix(agents): restore optional MCP requirements on daemon hydration 2026-10-29 08:59:45 +01:00
Mohamed Boudra e1684aece0 fix(agents): make injected MCP launch requirements explicit
Optional injected servers must survive persistence and reload without turning missing transport support into launch refusal. Required external and plugin servers keep their existing refusal. Advertise the optional MCP contract so older hosts retain established strict launch behavior.
2026-10-29 08:58:25 +01:00
Mohamed BoudraandRyan Winograd c47e1f178b fix(providers): allow optional exact MCP preapproval
Separate automatic approval convenience from strict tool-policy requirements so Hub can launch any available provider. Unsupported providers retain ordinary MCP injection and native permissions; strict requests still fail closed.

Co-authored-by: Ryan Winograd <ryan@thewinograds.com>
2026-10-29 08:51:20 +01:00
Mohamed Boudra 7c88a48be4 Publish store screenshots from the repo on each stable release (#8186)
* feat(release): publish store screenshots from the repo

Stable releases now upload the committed store renders: the App Store
review lane attaches the iPhone and iPad sets to the version it submits,
and a new Play lane replaces the phone and tablet screenshots after the
Android submit. F-Droid reads the same Android files, which now link to
the store renders instead of the website images.

The Play lane needs its own copy of the submit service-account key
(GOOGLE_PLAY_JSON_KEY in the EAS production environment) because the key
Expo stores for eas submit is not available to custom jobs, and EAS
Metadata does not handle screenshots for either store.

* fix(release): rewrite the Android screenshot links on every render

Google Play and F-Droid read numbered links to the Google renders. The
renderer now rewrites them from the panels it exported, so adding or
renaming a panel reaches all three stores.
2026-10-29 14:39:42 +07:00
paseo-bot[bot]andZeno_ 3196260bd4 Show completed edxeth Pi children as finished, live and after restart (#8176)
* fix(pi): settle edxeth subagent lifecycle events

* test(pi): preserve completed children when restoring captured histories

Verify the completed child and assistant transcript through history replay and the store consumed by clients. Both captured completion protocols otherwise leave a running descriptor.

Co-authored-by: Zeno_ <lin1071833138@gmail.com>

---------

Co-authored-by: Zeno_ <lin1071833138@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 07:06:46 +00:00
44 changed files with 1299 additions and 280 deletions
+4
View File
@@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
## Publish
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
+19 -2
View File
@@ -9,7 +9,7 @@
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(root, "../..");
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
const output = path.join(root, "output");
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
@@ -116,6 +117,7 @@ const STORES = {
},
google: {
device: "iphone",
androidScreenshots: "phoneScreenshots",
width: 1440,
height: 2560,
textTop: 150,
@@ -134,6 +136,7 @@ const STORES = {
},
"google-tablet": {
device: "ipad",
androidScreenshots: "tenInchScreenshots",
width: 1800,
height: 3200,
textTop: 200,
@@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) {
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
}
// Google Play and F-Droid read their screenshots from the Android metadata,
// which holds links to these renders. Rewriting the links on every render keeps
// added, removed and renamed panels in step with the App Store.
async function linkAndroidScreenshots(folder, files) {
const dir = path.join(androidImages, folder);
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { recursive: true });
for (const [index, file] of files.entries()) {
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
}
}
async function renderPreview(rows) {
const tile = 440;
const gap = 40;
@@ -387,7 +402,9 @@ try {
const marks = await loadAgentMarks(work);
const rows = [];
for (const [name, store] of Object.entries(STORES)) {
rows.push(await renderStore(browser, name, store, font, marks, work));
const files = await renderStore(browser, name, store, font, marks, work);
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
rows.push(files);
}
await renderPreview(rows);
} finally {
+4 -1
View File
@@ -215,7 +215,8 @@ fastlane/metadata/android/
│ ├── images/
│ │ ├── icon.png (512x512)
│ │ ├── featureGraphic.png (1024x500)
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ └── changelogs/ (generated — see below)
├── ja/
└── zh-CN/
@@ -223,6 +224,8 @@ fastlane/metadata/android/
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
+9
View File
@@ -44,6 +44,15 @@ fall back to creating a fresh agent when continuation was requested.
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
`server_info.features.optionalMcpSupport` advertises optional MCP launch requirements.
Hub sends `toolPolicy.enforcement: "if-supported"` and
`mcpServerRequirements: { hub: "optional" }` only when this flag is true.
Without it, Hub preserves its existing strict tool-policy request. Available
providers remain selectable; providers lacking strict approval support need an
updated daemon to launch from Hub. Required external/plugin servers and explicitly
required tool policies keep their existing refusal semantics.
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
Provider controls remain provider-native; see [providers.md](providers.md).
+10 -3
View File
@@ -30,9 +30,16 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
Exact MCP preapproval is a separate daemon-owned contract. A required tool policy
(the default) fails closed unless the provider can approve exact injected server/tool
identities without approving native tools. Native agent creation also accepts
`toolPolicy.enforcement: "if-supported"` for automatic approval convenience:
unsupported providers retain ordinary MCP configuration and native permissions.
Hub uses that optional request; MCP support and exact approval are not provider
eligibility requirements. Optional injection is declared separately with
`mcpServerRequirements: { [serverName]: "optional" }`; other injected servers
(including plugin-owned servers) keep their required semantics. The daemon
advertises this native launch contract as `features.optionalMcpSupport`. The legacy Hub create API remains strict-only.
## Core adapter patterns
+13 -2
View File
@@ -305,8 +305,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
@@ -317,6 +317,16 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
### Store screenshots
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
### Watching mobile builds from the terminal
Use the EAS CLI from `packages/app/`:
@@ -359,6 +369,7 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
@@ -1 +1 @@
../../../../../../packages/website/public/phone-1.png
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
@@ -1 +1 @@
../../../../../../packages/website/public/phone-2.png
../../../../../../assets/store-screenshots/output/google/02-chat.png
@@ -1 +1 @@
../../../../../../packages/website/public/phone-3.png
../../../../../../assets/store-screenshots/output/google/03-plugins.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google/04-review.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google/05-terminal.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
@@ -42,6 +42,20 @@ jobs:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: production
upload_android_screenshots:
name: Upload Google Play screenshots
needs: [build_android, submit_android]
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
bundle exec fastlane android upload_screenshots
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, distribute_ios]
+48 -1
View File
@@ -1,7 +1,31 @@
require "fileutils"
require "tmpdir"
default_platform(:ios)
APP_IDENTIFIER = "sh.paseo"
# Store screenshots are rendered into assets/store-screenshots/output; see the
# README there. Google Play and F-Droid both read fastlane/metadata/android at
# the repo root, whose screenshot files link to those renders.
REPO_ROOT = File.expand_path("../../..", __dir__)
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
# deliver reads screenshots from one folder per language and picks the device
# slot from each image's size, so both Apple sets go into en-US together.
def stage_app_store_screenshots
root = Dir.mktmpdir("app-store-screenshots")
locale = File.join(root, "en-US")
FileUtils.mkdir_p(locale)
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
end
end
root
end
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
@@ -87,7 +111,8 @@ platform :ios do
},
force: true,
skip_binary_upload: true,
skip_screenshots: true,
screenshots_path: stage_app_store_screenshots,
overwrite_screenshots: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,
@@ -96,3 +121,25 @@ platform :ios do
)
end
end
platform :android do
desc "Replace the Google Play store screenshots with the ones in the repo"
lane :upload_screenshots do
# supply attaches listing changes to a release on the production track.
# The release workflow passes the version code it just submitted; without
# it, supply uses the track's only release.
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
supply(
package_name: APP_IDENTIFIER,
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
metadata_path: ANDROID_METADATA,
version_code: version_code.empty? ? nil : version_code.to_i,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
sync_image_upload: true,
)
end
end
+4
View File
@@ -509,10 +509,14 @@ export interface McpToolRef {
}
export interface ToolPolicy {
/** Missing or required fails closed; if-supported requests approval convenience only. */
enforcement?: "required" | "if-supported";
preapproved: McpToolRef[];
}
export interface AgentSessionConfig {
/** Per-server launch requirement; unspecified servers are required. */
mcpServerRequirements?: Record<string, "optional" | "required">;
provider: AgentProvider;
cwd: string;
/**
+35
View File
@@ -571,3 +571,38 @@ test.each(["all", "base", "uncommitted"] as const)(
expect(parsed.attachments).toEqual([attachment]);
},
);
test("native agent creation accepts optional approval without weakening legacy strict acknowledgement", () => {
const toolPolicy = {
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
enforcement: "if-supported",
};
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "req",
config: { provider: "pi", cwd: "/tmp", toolPolicy },
});
expect(parsed).toMatchObject({ config: { toolPolicy } });
expect(
SessionInboundMessageSchema.safeParse({
type: "hub.execution.agent.create.request",
requestId: "req",
executionId: "exec",
provider: "pi",
cwd: "/tmp",
prompt: "test",
toolPolicy: { preapproved: toolPolicy.preapproved },
}).success,
).toBe(true);
expect(
SessionInboundMessageSchema.safeParse({
type: "hub.execution.agent.create.request",
requestId: "req",
executionId: "exec",
provider: "pi",
cwd: "/tmp",
prompt: "test",
toolPolicy,
}).success,
).toBe(false);
});
+5 -1
View File
@@ -490,8 +490,11 @@ const AgentSessionConfigSchema = z.object({
featureValues: z.record(z.string(), z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
providerOptions: ProviderOptionsSchema.optional(),
toolPolicy: ToolPolicySchema.optional(),
toolPolicy: ToolPolicySchema.extend({
enforcement: z.enum(["required", "if-supported"]).optional(),
}).optional(),
systemPrompt: z.string().optional(),
mcpServerRequirements: z.record(z.string(), z.enum(["optional", "required"])).optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
});
@@ -3671,6 +3674,7 @@ export const ServerInfoStatusPayloadSchema = z
internalWorkspaces: z.boolean().optional(),
backgroundWorkspaces: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
optionalMcpSupport: z.boolean().optional(),
connectedHosts: z.boolean().optional(),
hostMultiplexing: z.boolean().optional(),
usageSources: z.boolean().optional(),
@@ -19,6 +19,7 @@ import {
type AgentManagerEvent,
type ManagedAgent,
} from "./agent-manager.js";
import { buildProviderRegistry } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { toAgentPayload } from "./agent-projections.js";
@@ -445,7 +446,7 @@ class EnvProbeAgentClient extends TestAgentClient {
}
class TestAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly provider: AgentProvider;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private runtimeModel: string | null = null;
@@ -453,7 +454,9 @@ class TestAgentSession implements AgentSession {
private turnIdCounter = 0;
private interrupted = false;
constructor(private readonly config: AgentSessionConfig) {}
constructor(private readonly config: AgentSessionConfig) {
this.provider = config.provider;
}
async run(): Promise<AgentRunResult> {
return {
@@ -3822,54 +3825,85 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in
expect(snapshot.config.mcpServers).toBeUndefined();
});
test("createAgent preserves a user-provided paseo MCP config", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
test.each([
{ provider: "pi", supportsMcp: true },
{ provider: "pi", supportsMcp: false },
{ provider: "codex", supportsMcp: false },
])(
"createAgent permits optional approval for $provider with MCP capability $supportsMcp",
async ({ provider, supportsMcp }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return new McpCapableTestAgentSession(config);
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return supportsMcp ? new McpCapableTestAgentSession(config) : new TestAgentSession(config);
}
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
const client = new CaptureClient(provider);
const manager = new AgentManager({
clients: {
[provider]: client,
},
providerDefinitions: {
[provider]: {
enabled: true,
applyToolPolicy: buildProviderRegistry(logger)[provider]!.applyToolPolicy,
},
},
},
undefined,
{ workspaceId: undefined },
);
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
});
const snapshot = await manager.createAgent(
{
provider,
mcpServerRequirements: { paseo: "optional" },
cwd: workdir,
toolPolicy: {
enforcement: "if-supported",
preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }],
},
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
},
},
undefined,
{ workspaceId: undefined },
);
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
expect((await storage.get(snapshot.id))?.config?.mcpServerRequirements).toEqual({
paseo: "optional",
});
await manager.reloadAgentSession(snapshot.id);
expect(manager.getAgent(snapshot.id)?.config.mcpServerRequirements).toEqual({
paseo: "optional",
});
const expectedPolicy =
provider === "codex"
? { preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }] }
: undefined;
expect(snapshot.config.toolPolicy).toEqual(expectedPolicy);
expect(client.lastConfig?.toolPolicy).toEqual(expectedPolicy);
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
},
);
test("createAgent fails when cwd does not exist", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
@@ -12575,3 +12609,21 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]);
});
test("MCP requirements cannot mark a later plugin server optional", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
await expect(
manager.createAgent(
{ provider: "codex", cwd: workdir, mcpServerRequirements: { plugin: "optional" } },
undefined,
{ workspaceId: undefined },
),
).rejects.toThrow(
"MCP server requirement 'plugin' requires MCP server 'plugin' in the same agent request",
);
});
@@ -216,6 +216,8 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
if (record.config.providerOptions != null) {
config.providerOptions = record.config.providerOptions;
}
if (record.config.mcpServerRequirements != null)
config.mcpServerRequirements = record.config.mcpServerRequirements;
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
if (record.config.systemPrompt != null) {
config.systemPrompt = record.config.systemPrompt;
@@ -1686,7 +1688,7 @@ export class AgentManager {
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
this.hasRequiredExternalMcpServers(providerLaunchConfig) &&
existing.session.capabilities.supportsMcpServers !== true
) {
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
@@ -3888,13 +3890,19 @@ export class AgentManager {
}
}
private hasRequiredExternalMcpServers(config: AgentSessionConfig): boolean {
return Object.keys(stripInternalPaseoMcpServer(config).mcpServers ?? {}).some(
(name) => config.mcpServerRequirements?.[name] !== "optional",
);
}
private async requireExternalMcpSupport(
session: AgentSession,
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): Promise<void> {
if (
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
!this.hasRequiredExternalMcpServers(launchConfig) ||
session.capabilities.supportsMcpServers === true
) {
return;
@@ -5457,6 +5465,7 @@ export class AgentManager {
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
const definition = this.providerDefinitions.get(config.provider);
this.validateToolPolicyServers(config);
this.validateMcpServerRequirements(config);
if (config.toolPolicy && !definition?.applyToolPolicy) {
throw new Error(
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
@@ -5467,6 +5476,16 @@ export class AgentManager {
: config;
}
private validateMcpServerRequirements(config: AgentSessionConfig): void {
for (const name of Object.keys(config.mcpServerRequirements ?? {})) {
if (!Object.hasOwn(config.mcpServers ?? {}, name)) {
throw new Error(
`MCP server requirement '${name}' requires MCP server '${name}' in the same agent request`,
);
}
}
}
private validateToolPolicyServers(config: AgentSessionConfig): void {
if (!config.toolPolicy) return;
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
@@ -395,6 +395,9 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
if (config.systemPrompt) {
serializable.systemPrompt = config.systemPrompt;
}
if (config.mcpServerRequirements) {
serializable.mcpServerRequirements = config.mcpServerRequirements;
}
if (config.mcpServers) {
serializable.mcpServers = config.mcpServers;
}
@@ -607,6 +607,7 @@ export interface AgentSessionConfig {
title?: string | null;
providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy;
mcpServerRequirements?: Record<string, "optional" | "required">;
mcpServers?: Record<string, McpServerConfig>;
/**
* Internal agents are hidden from listings and don't trigger notifications.
@@ -27,6 +27,10 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
.nullable()
.optional(),
systemPrompt: z.string().nullable().optional(),
mcpServerRequirements: z
.record(z.string(), z.enum(["optional", "required"]))
.nullable()
.optional(),
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
})
.nullable()
@@ -89,6 +93,7 @@ export type SerializableAgentConfig = Pick<
| "toolPolicy"
| "systemPrompt"
| "mcpServers"
| "mcpServerRequirements"
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
@@ -135,6 +135,29 @@ describe("provider-owned option schemas", () => {
});
describe("exact MCP preapproval mappings", () => {
test("optional exact preapproval permits unsupported providers without changing their MCP config", () => {
const registry = buildProviderRegistry(createTestLogger());
const config = {
provider: "pi",
cwd: "/tmp",
mcpServers: { hub: { type: "http" as const, url: "http://localhost/hub" } },
};
expect(
registry.pi.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" }),
).toEqual({ ...config, toolPolicy: undefined });
});
test.each(["claude", "codex", "opencode"])(
"optional grants retain exact approval for %s",
(provider) => {
const registry = buildProviderRegistry(createTestLogger());
const config = { provider, cwd: "/tmp" };
expect(
registry[provider]!.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" })
.toolPolicy,
).toEqual(hubPolicy);
},
);
test("unsupported providers fail closed with an unattended-execution error", () => {
const registry = buildProviderRegistry(createTestLogger());
expect(() =>
@@ -652,12 +652,15 @@ function createRegistryEntry(
supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval,
applyToolPolicy: (config, toolPolicy) => {
if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) {
throw new ToolPolicyUnsupportedError(provider);
if (toolPolicy.enforcement !== "if-supported")
throw new ToolPolicyUnsupportedError(provider);
return { ...config, toolPolicy: undefined };
}
const exactPolicy = toolPolicy ? { preapproved: toolPolicy.preapproved } : undefined;
return {
...config,
toolPolicy: toolPolicy
? (resolved.contract.applyToolPolicy?.(provider, toolPolicy) ?? toolPolicy)
toolPolicy: exactPolicy
? (resolved.contract.applyToolPolicy?.(provider, exactPolicy) ?? exactPolicy)
: undefined,
};
},
@@ -0,0 +1,8 @@
{"type": "session", "version": 3, "id": "e56a0006-f421-45be-9aef-e4019db1cf24", "timestamp": "2026-10-21T13:05:57.384Z", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/lean/sessions/2026-10-21T13-05-53-085Z_01a15e91-73bd-7282-82c6-d3974711a941.jsonl"}
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:57.385Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": true, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/lean/agent", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "boundarySystemPrompt": false}, "id": "57355eeb", "parentId": null, "timestamp": "2026-10-21T13:05:57.386Z"}
{"type": "session_info", "id": "847415e4", "parentId": "57355eeb", "timestamp": "2026-10-21T13:05:57.566Z", "name": "[marker] Fixture marker verification"}
{"type": "model_change", "id": "d4ad59be", "parentId": "847415e4", "timestamp": "2026-10-21T13:05:57.672Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
{"type": "thinking_level_change", "id": "7f6bf9d2", "parentId": "d4ad59be", "timestamp": "2026-10-21T13:05:57.672Z", "thinkingLevel": "high"}
{"type": "message", "id": "737b762e", "parentId": "6b436446", "timestamp": "2026-10-21T13:05:57.679Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e91-73bd-7282-82c6-d3974711a941/context/fixture-marker-2026-10-21T13-05-57.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_LEAN_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587957678}}
{"type": "message", "id": "100b110d", "parentId": "737b762e", "timestamp": "2026-10-21T13:06:02.050Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "PASEO_EDXETH_LEAN_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1713, "output": 13, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalTokens": 1726, "cost": {"input": 0.006852, "output": 0.00026000000000000003, "cacheRead": 0, "cacheWrite": 0, "total": 0.007111999999999999}}, "stopReason": "stop", "timestamp": 1792587957684, "responseId": "resp_0bc8bfaee079148a016ad8b8b7edb08191924676ed8dcfd447", "rawStopReason": "completed"}}
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "80bbd3bb", "parentId": "100b110d", "timestamp": "2026-10-21T13:06:02.054Z"}
@@ -0,0 +1,263 @@
{
"provenance": {
"package": "Zeno2019/pi-subagents-lean",
"version": "2.9.2-lean.1",
"sourceCommit": "e10fe855808abcae193a205ea98f8ccd70d25620",
"piVersion": "0.87.1",
"captureDate": "2026-10-21"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"args": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
}
],
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json"
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
"noSession": false,
"status": "started",
"mode": "background",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"autoExit": true
}
},
"isError": false
},
{
"type": "message_end",
"message": {
"role": "custom",
"customType": "subagent_result",
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
"display": true,
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"mode": "background",
"status": "completed",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 13,
"contextTokens": 1775,
"contextWindow": 372000,
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
},
"timestamp": 1792587964414
}
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"name": "subagent",
"arguments": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK"
}
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 2020,
"output": 43,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 2063,
"cost": {
"input": 0.00808,
"output": 0.0008600000000000001,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.00894
}
},
"stopReason": "toolUse",
"timestamp": 1792587953256,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b3698481918f82962b6eaf2788",
"rawStopReason": "completed"
},
{
"role": "toolResult",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
}
],
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
"noSession": false,
"status": "started",
"mode": "background",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"autoExit": true
},
"isError": false,
"timestamp": 1792587957399
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "**Identifying need for await keyword**"
},
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 2258,
"output": 18,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 11,
"totalTokens": 2276,
"cost": {
"input": 0.009032,
"output": 0.00036,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.009392
}
},
"stopReason": "stop",
"timestamp": 1792587957402,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b602108191a8e8f7fd77fbe5b5",
"rawStopReason": "completed"
},
{
"role": "custom",
"customType": "subagent_result",
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
"display": true,
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"mode": "background",
"status": "completed",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 13,
"contextTokens": 1775,
"contextWindow": 372000,
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
},
"timestamp": 1792587964414
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 389,
"output": 5,
"cacheRead": 2176,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 2570,
"cost": {
"input": 0.0015559999999999999,
"output": 0.0001,
"cacheRead": 0.0008704000000000001,
"cacheWrite": 0,
"total": 0.0025264
}
},
"stopReason": "stop",
"timestamp": 1792587964417,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8bd0c548191bddd1fe3410a1c82",
"rawStopReason": "completed"
}
]
}
@@ -0,0 +1,8 @@
{"type": "session", "version": 3, "id": "afbea6e0-7858-4ecf-8ef1-e26af9b7d6e0", "timestamp": "2026-10-21T13:05:17.628Z", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/upstream/sessions/2026-10-21T13-05-12-212Z_01a15e90-d414-7243-a701-4eb4b0bec880.jsonl"}
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:17.628Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": false, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/upstream/agent", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "boundarySystemPrompt": false}, "id": "3f7d3c3f", "parentId": null, "timestamp": "2026-10-21T13:05:17.629Z"}
{"type": "session_info", "id": "6e198450", "parentId": "3f7d3c3f", "timestamp": "2026-10-21T13:05:17.825Z", "name": "[marker] Fixture marker verification"}
{"type": "model_change", "id": "94bf4c0b", "parentId": "6e198450", "timestamp": "2026-10-21T13:05:17.962Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
{"type": "thinking_level_change", "id": "57cc6428", "parentId": "94bf4c0b", "timestamp": "2026-10-21T13:05:17.962Z", "thinkingLevel": "high"}
{"type": "message", "id": "a9e47f08", "parentId": "8c186624", "timestamp": "2026-10-21T13:05:17.970Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e90-d414-7243-a701-4eb4b0bec880/context/fixture-marker-2026-10-21T13-05-17.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_UPSTREAM_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587917968}}
{"type": "message", "id": "bc221a13", "parentId": "a9e47f08", "timestamp": "2026-10-21T13:05:23.476Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "**Following user file marker exactly**"}, {"type": "text", "text": "PASEO_EDXETH_UPSTREAM_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1673, "output": 49, "cacheRead": 0, "cacheWrite": 0, "reasoning": 34, "totalTokens": 1722, "cost": {"input": 0.006692, "output": 0.0009800000000000002, "cacheRead": 0, "cacheWrite": 0, "total": 0.007672}}, "stopReason": "stop", "timestamp": 1792587917974, "responseId": "resp_09445c1733d532e7016ad8b8909c908191ba803c059497945e", "rawStopReason": "completed"}}
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "832fc8c9", "parentId": "bc221a13", "timestamp": "2026-10-21T13:05:23.477Z"}
@@ -0,0 +1,156 @@
{
"provenance": {
"package": "edxeth/pi-subagents",
"version": "2.9.2",
"sourceCommit": "cf6dbf41c17986f3882e6804a68e8fb8282d25e6",
"piVersion": "0.87.1",
"captureDate": "2026-10-21"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"args": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
}
],
"details": {
"id": "dd7f94e8",
"name": "fixture-marker",
"agent": "marker",
"status": "completed",
"mode": "background",
"parentClosePolicy": "terminate",
"deliveryState": "awaited",
"async": false,
"autoExit": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 49,
"contextTokens": 1777,
"contextWindow": 372000,
"summary": "PASEO_EDXETH_UPSTREAM_OK",
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
}
},
"isError": false
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"name": "subagent",
"arguments": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
}
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 3256,
"output": 43,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 3299,
"cost": {
"input": 0.013023999999999999,
"output": 0.0008600000000000001,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.013883999999999999
}
},
"stopReason": "toolUse",
"timestamp": 1792587912428,
"responseId": "resp_0a0ca4aa78cd296a016ad8b88bd52881918cbb9b8326b3060a",
"rawStopReason": "completed"
},
{
"role": "toolResult",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
}
],
"details": {
"id": "dd7f94e8",
"name": "fixture-marker",
"agent": "marker",
"status": "completed",
"mode": "background",
"parentClosePolicy": "terminate",
"deliveryState": "awaited",
"async": false,
"autoExit": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 49,
"contextTokens": 1777,
"contextWindow": 372000,
"summary": "PASEO_EDXETH_UPSTREAM_OK",
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
},
"isError": false,
"timestamp": 1792587924671
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 388,
"output": 5,
"cacheRead": 3072,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 3465,
"cost": {
"input": 0.001552,
"output": 0.0001,
"cacheRead": 0.0012288000000000002,
"cacheWrite": 0,
"total": 0.0028808
}
},
"stopReason": "stop",
"timestamp": 1792587924675,
"responseId": "resp_0a0ca4aa78cd296a016ad8b896af2c81919c0ef586dd50f2f0",
"rawStopReason": "completed"
}
]
}
@@ -0,0 +1,336 @@
import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { createPiExtensionHost } from "../index.js";
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { edxethPiSubagents } from "./index.js";
import { streamPiHistory } from "../../history-mapper.js";
import { ProviderSubagentStore } from "../../../../provider-subagents/store.js";
const args = {
agent: "scout",
name: "status-scout",
title: "Check child status",
task: "Return the completion marker",
};
describe("edxeth/pi-subagents adapter", () => {
test.each(["tool", "notification"])("ends a child that requested help via %s", (delivery) => {
const host = createPiExtensionHost();
const call = { callId: "spawn-1", toolName: "subagent", args, status: "completed" as const };
host.mapToolCall({
...call,
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
const details = { id: "run-1", name: args.name, message: "Please clarify the task" };
const result =
delivery === "tool"
? host.mapToolCall({ ...call, result: { details: { ...details, status: "pinged" } } })
: host.mapCustomMessage({
role: "custom",
customType: "subagent_ping",
content: "Child requested help and exited",
details,
});
expect(result?.subagents).toEqual([
expect.objectContaining({
type: "upsert",
id: "spawn-1",
status: "canceled",
subtitle: "Needs help",
}),
]);
});
test("leaves unrelated tools and other subagent protocols to their adapters", () => {
const host = createPiExtensionHost(undefined, [edxethPiSubagents]);
const call = { callId: "other", toolName: "subagent", args, status: "completed" as const };
for (const details of [null, {}, { mode: "single", results: [] }, { id: "run-1" }]) {
expect(host.mapToolCall({ ...call, result: { details } })).toBeUndefined();
}
expect(
host.mapToolCall({
...call,
toolName: "unrelated",
result: { details: { id: "run-1", name: args.name, status: "completed" } },
}),
).toBeUndefined();
for (const details of [{ id: "run-1" }, { id: "run-1", status: "unknown" }]) {
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Unrecognized result",
details,
}),
).toBeUndefined();
}
});
test("waits for cancellation after a kill acknowledgement reports stopping", () => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
const stopping = host.mapToolCall({
callId: "kill-1",
toolName: "subagent_kill",
args: { id: "run-1" },
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "stopping" } },
});
expect(stopping?.subagents ?? []).toEqual([]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Cancelled",
details: { id: "run-1", status: "cancelled" },
})?.subagents,
).toEqual([{ type: "upsert", id: "spawn-1", status: "canceled" }]);
});
test("preserves a completion that arrives before its launch acknowledgement", () => {
const host = createPiExtensionHost();
const early = host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Already done",
details: { id: "fast-run", status: "completed" },
});
expect(early?.subagents ?? []).toEqual([]);
const started = host.mapToolCall({
callId: "fast-call",
toolName: "subagent",
args,
status: "completed",
result: {
details: { id: "fast-run", name: args.name, agent: args.agent, status: "started" },
},
});
expect(started?.subagents).toEqual([
{
type: "upsert",
id: "fast-call",
title: "scout",
description: args.task,
toolCallId: "fast-call",
status: "completed",
},
]);
});
test.each([
{ details: { status: "completed" }, expected: "completed" },
{ details: { status: "failed" }, expected: "failed" },
{ details: { status: "cancelled" }, expected: "canceled" },
{ details: { error: "Child watcher exited unexpectedly" }, expected: "failed" },
])("maps completion $details to $expected", ({ details, expected }) => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Child result",
details: { id: "run-1", ...details },
})?.subagents,
).toEqual([{ type: "upsert", id: "spawn-1", status: expected }]);
});
test("a resume gets its own run and reads the retained child session", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-edxeth-resume-"));
const file = join(dir, "child.jsonl");
const host = createPiExtensionHost();
try {
await writeFile(
file,
JSON.stringify({
type: "message",
message: { role: "assistant", content: [{ type: "text", text: "RESUMED" }] },
}) + "\n",
);
const original = host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: {
details: { id: "run-1", name: args.name, status: "completed", sessionFile: file },
},
});
await original?.hydration;
const resumed = host.mapToolCall({
callId: "resume-1",
toolName: "subagent_resume",
args: { sessionFile: file, task: "Continue" },
status: "completed",
result: {
details: { id: "run-2", name: args.name, status: "started", sessionFile: file },
},
});
expect(resumed?.childSessions).toEqual([{ id: "resume-1", file }]);
expect(await resumed?.hydration).toEqual([
{
type: "provider_subagent",
provider: "pi",
event: {
type: "timeline",
id: "resume-1",
item: expect.objectContaining({ type: "assistant_message", text: "RESUMED" }),
},
},
]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Resumed run complete",
details: { id: "run-2", status: "completed", sessionFile: file },
})?.subagents,
).toEqual([{ type: "upsert", id: "resume-1", status: "completed" }]);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("settles each batched child by run id without creating a parent-call card", () => {
const host = createPiExtensionHost();
const result = host.mapToolCall({
callId: "batch-1",
toolName: "subagent",
args: { children: [args, { ...args, agent: "reviewer", name: "status-reviewer" }] },
status: "completed",
result: {
details: {
status: "batch",
children: [
{ id: "run-1", ...args, mode: "background", status: "completed" },
{ id: "run-2", name: "status-reviewer", agent: "reviewer", status: "started" },
],
},
},
});
expect(result?.subagents).toEqual([
{
type: "upsert",
id: "batch-1:0",
title: "scout",
description: args.task,
toolCallId: "batch-1",
status: "completed",
},
{
type: "upsert",
id: "batch-1:1",
title: "reviewer",
description: undefined,
toolCallId: "batch-1",
status: "running",
},
]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Done",
details: { id: "run-2", status: "completed" },
})?.subagents,
).toEqual([{ type: "upsert", id: "batch-1:1", status: "completed" }]);
});
test.each([
{ mode: "foreground", statuses: ["running", "completed"] },
{ mode: "background", statuses: ["running", "running", "completed"] },
])("settles the captured $mode run live and on replay", async ({ mode, statuses }) => {
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
});
const events = await verifySubagentFixture(fixture);
const updates = events.flatMap(({ event }) => (event.type === "upsert" ? [event] : []));
expect(updates.map((event) => event.status)).toEqual(statuses);
expect(new Set(updates.map((event) => event.id)).size).toBe(1);
expect(events.some(({ event }) => event.type === "timeline")).toBe(true);
});
test.each(["foreground", "background"])(
"restores a completed %s child and its transcript to the consumer store",
async (mode) => {
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
});
const store = new ProviderSubagentStore();
for await (const event of streamPiHistory("pi", fixture.messages)) {
if (event.type === "provider_subagent") store.apply("parent", "pi", event.event);
}
const children = store.list("parent");
expect(children).toEqual([expect.objectContaining({ status: "completed", title: "marker" })]);
const transcript = store.fetchTimeline("parent", children[0]!.id);
expect(transcript.rows).toContainEqual(
expect.objectContaining({
item: expect.objectContaining({
type: "assistant_message",
text: mode === "foreground" ? "PASEO_EDXETH_UPSTREAM_OK" : "PASEO_EDXETH_LEAN_OK",
}),
}),
);
},
);
test("settles a synchronous result on the card created when the tool started", () => {
const host = createPiExtensionHost();
const call = { callId: "spawn-1", toolName: "subagent", args };
const start = host.mapToolCall({ ...call, status: "running", result: null });
expect(start?.subagents).toEqual([
{
type: "upsert",
id: "spawn-1",
title: "scout",
description: args.task,
toolCallId: "spawn-1",
status: "running",
},
]);
const end = host.mapToolCall({
...call,
status: "completed",
result: {
content: [{ type: "text", text: "DONE" }],
details: {
id: "run-1",
name: args.name,
agent: args.agent,
mode: "background",
status: "completed",
deliveryState: "awaited",
exitCode: 0,
},
},
});
expect(end?.subagents).toEqual([
{
type: "upsert",
id: "spawn-1",
title: "scout",
description: args.task,
toolCallId: "spawn-1",
status: "completed",
},
]);
});
});
@@ -0,0 +1,119 @@
import { z } from "zod";
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
import type { PiChildSessionFile, PiExtension } from "../contract.js";
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
const Run = z
.object({
id: z.string().trim().min(1),
name: z.string().trim().min(1),
agent: z.string().trim().min(1).optional(),
task: z.string().optional(),
status: z.enum(["started", "completed", "failed", "cancelled", "pinged"]),
sessionFile: z.string().trim().min(1).optional(),
})
.passthrough();
const Args = z.object({ task: z.string().optional() }).passthrough();
const Batch = z.object({ status: z.enum(["started", "batch"]), children: z.array(Run).min(1) });
const Completion = z
.object({
id: z.string().trim().min(1),
status: z.enum(["completed", "failed", "cancelled", "pinged"]).optional(),
error: z.string().optional(),
sessionFile: z.string().trim().min(1).optional(),
})
.passthrough();
const Ping = Completion.extend({ message: z.string() });
function childStatus(status: z.infer<typeof Run>["status"]) {
if (status === "started") return "running";
// A ping ends the child process and needs a new resume run. It is not success.
if (status === "cancelled" || status === "pinged") return "canceled";
return status;
}
export const edxethPiSubagents: PiExtension = {
id: "edxeth/pi-subagents",
createSession: () => {
const callsByRun = new Map<string, string>();
const completions = new Map<string, z.infer<typeof Completion>>();
const readSessions = new Map<string, string>();
const childSession = (id: string, file: string | undefined): PiChildSessionFile[] => {
if (!file || readSessions.get(id) === file) return [];
readSessions.set(id, file);
return [{ id, file }];
};
const completeRun = (result: z.infer<typeof Completion>) => {
const status = result.status ?? (result.error === undefined ? undefined : "failed");
if (!status) return undefined;
// A fast child can finish before the tool returns its run id. Keep the
// terminal outcome so a later "started" acknowledgement cannot regress it.
completions.set(result.id, { ...result, status });
const id = callsByRun.get(result.id);
if (!id) return undefined;
const subagents: ProviderSubagentInputEvent[] = [
{
type: "upsert",
id,
status: childStatus(status),
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
},
];
return { subagents, childSessions: childSession(id, result.sessionFile) };
};
return {
mapToolCall(call) {
const isLaunch = call.toolName === "subagent" || call.toolName === "subagent_resume";
if (!isLaunch || call.status === "running") return undefined;
const details = typeof call.result === "object" ? call.result?.details : null;
const single = Run.safeParse(details);
let runs: z.infer<typeof Run>[];
if (single.success) {
runs = [single.data];
} else {
const batch = Batch.safeParse(details);
if (!batch.success) return undefined;
runs = batch.data.children;
}
const args = Args.safeParse(call.args);
const description = args.success ? args.data.task : undefined;
const subagents: ProviderSubagentInputEvent[] = [];
const childSessions: PiChildSessionFile[] = [];
for (const [index, run] of runs.entries()) {
const id = runs.length === 1 ? call.callId : `${call.callId}:${index}`;
const completion = completions.get(run.id);
const status = completion?.status ?? run.status;
callsByRun.set(run.id, id);
subagents.push({
type: "upsert",
id,
title: run.agent ?? run.name,
description: run.task ?? description,
toolCallId: call.callId,
status: childStatus(status),
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
});
childSessions.push(...childSession(id, completion?.sessionFile ?? run.sessionFile));
}
return {
detail: {
type: "sub_agent",
description,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
},
subagents,
childSessions,
};
},
mapCustomMessage(message) {
if (message.customType === "subagent_ping") {
const ping = Ping.safeParse(message.details);
return ping.success ? completeRun({ ...ping.data, status: "pinged" }) : undefined;
}
if (message.customType !== "subagent_result") return undefined;
const parsed = Completion.safeParse(message.details);
return parsed.success ? completeRun(parsed.data) : undefined;
},
};
},
};
@@ -1,5 +1,6 @@
import { piAskUser } from "./pi-ask-user/index.js";
import { piSubagents } from "./pi-subagents/index.js";
import { edxethPiSubagents } from "./edxeth-pi-subagents/index.js";
import { piMcpAdapter } from "./pi-mcp-adapter/index.js";
import { rpivTodo } from "./rpiv-todo/index.js";
import { piExampleTodo } from "./pi-example-todo/index.js";
@@ -10,6 +11,7 @@ import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js";
export const piExtensions = [
piAskUser, // Dialog correlation
edxethPiSubagents, // Structured results before the generic subagent tool matcher
piSubagents, // Delegation calls
tintinwebPiSubagents,
gotgenesPiSubagents,
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
import { withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
const BASE_CONFIG: AgentSessionConfig = {
provider: "claude",
@@ -49,3 +49,15 @@ describe("withRuntimePaseoMcpServer", () => {
expect(result.mcpServers).toBeUndefined();
});
});
test("stripping an internal MCP server also strips its launch requirement", () => {
const result = stripInternalPaseoMcpServer({
...BASE_CONFIG,
mcpServerRequirements: { paseo: "optional", hub: "optional" },
mcpServers: {
paseo: { type: "http", url: "http://127.0.0.1/mcp/agents?callerAgentId=agent-1" },
hub: { type: "http", url: "https://hub.test/mcp" },
},
});
expect(result.mcpServerRequirements).toEqual({ hub: "optional" });
});
@@ -18,6 +18,11 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe
delete nextMcpServers[PASEO_MCP_SERVER_NAME];
const next = { ...config };
if (next.mcpServerRequirements?.[PASEO_MCP_SERVER_NAME]) {
next.mcpServerRequirements = { ...next.mcpServerRequirements };
delete next.mcpServerRequirements[PASEO_MCP_SERVER_NAME];
if (Object.keys(next.mcpServerRequirements).length === 0) delete next.mcpServerRequirements;
}
if (Object.keys(nextMcpServers).length > 0) {
next.mcpServers = nextMcpServers;
} else {
@@ -214,3 +214,14 @@ describe("persistence hooks", () => {
expect(handle).toBeNull();
});
});
test("restores optional MCP requirements for continuation", () => {
const record = createRecord({
config: {
mcpServerRequirements: { hub: "optional" },
mcpServers: { hub: { type: "http", url: "https://hub.test/mcp" } },
},
});
expect(buildConfigOverrides(record).mcpServerRequirements).toEqual({ hub: "optional" });
expect(buildSessionConfig(record)?.mcpServerRequirements).toEqual({ hub: "optional" });
});
@@ -78,6 +78,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
toolPolicy: record.config?.toolPolicy ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
mcpServers: record.config?.mcpServers ?? undefined,
mcpServerRequirements: record.config?.mcpServerRequirements ?? undefined,
});
}
@@ -100,6 +101,7 @@ export function buildSessionConfig(
toolPolicy: overrides.toolPolicy,
systemPrompt: overrides.systemPrompt,
mcpServers: overrides.mcpServers,
mcpServerRequirements: overrides.mcpServerRequirements,
});
}
@@ -1966,6 +1966,7 @@ export class VoiceAssistantWebSocketServer {
workspaceSortTimes: true,
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
optionalMcpSupport: true,
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: true,
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
@@ -22,7 +22,6 @@ import {
writeTerminalWorkerRecord,
type TerminalWorkerRecord,
} from "./terminal-worker-record.js";
import { createTerminalWorkerRecordPublisher } from "./terminal-worker-record-publisher.js";
import { resolveTerminalWorkerSocketPath } from "./terminal-worker-socket-path.js";
import {
TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV,
@@ -218,17 +217,13 @@ function runWorker(argv: string[]): void {
controller?.send(message);
}
const recordPublisher = createTerminalWorkerRecordPublisher((state) => {
writeTerminalWorkerRecord(args.paseoHome, state);
});
async function persistRecord(): Promise<void> {
function persistRecord(): void {
if (!record || exiting) return;
record = {
...record,
terminals: record.terminals.filter((entry) => sessionsById.has(entry.id)),
};
await recordPublisher.publish(record);
writeTerminalWorkerRecord(args.paseoHome, record);
}
function removeOwnFiles(): void {
@@ -241,7 +236,6 @@ function runWorker(argv: string[]): void {
function exitWorker(code: number): void {
if (exiting) return;
exiting = true;
recordPublisher.stop();
removeOwnFiles();
server?.close();
process.exit(code);
@@ -370,9 +364,7 @@ function runWorker(argv: string[]): void {
outputCoalescer.flush();
clearTerminalSubscriptions(session.id);
sessionsById.delete(session.id);
void persistRecord().catch((error) => {
if (!exiting) console.error("Cannot publish terminal exit:", error);
});
persistRecord();
if (exitsHeldDuringCreation.has(session.id)) {
exitsHeldDuringCreation.set(session.id, info);
return;
@@ -447,7 +439,7 @@ function runWorker(argv: string[]): void {
{ id: session.id, pid, startedAt: inspection.snapshot.startedAt },
],
};
await persistRecord();
persistRecord();
}
function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise<void> {
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest";
import { setTimeout as delay } from "node:timers/promises";
import { createTerminalWorkerRecordPublisher } from "./terminal-worker-record-publisher.js";
import type { TerminalWorkerRecord } from "./terminal-worker-record.js";
const record: TerminalWorkerRecord = {
recordId: "worker",
pid: 123,
bootId: null,
createdAt: "now",
identity: { startedAt: "now", commandLine: "worker" },
protocolVersion: 1,
paseoVersion: "test",
socketPath: "socket",
secret: "secret",
terminals: [{ id: "terminal", pid: 124, startedAt: "now" }],
};
describe("live worker record publication", () => {
it.each(["EPERM", "EBUSY", "EACCES"])(
"publishes the latest state after %s contention clears",
async (code) => {
let locked = true;
const written: TerminalWorkerRecord[] = [];
const publisher = createTerminalWorkerRecordPublisher((state) => {
if (locked) throw Object.assign(new Error("reader holds record"), { code });
written.push(state);
});
try {
const creation = publisher.publish(record);
const exited = { ...record, terminals: [] };
const exit = publisher.publish(exited);
expect(written).toEqual([]);
locked = false;
await Promise.all([creation, exit]);
expect(written).toEqual([exited]);
} finally {
publisher.stop();
}
},
);
it("does not recreate a retired record when a reader releases it", async () => {
let locked = true;
const written: TerminalWorkerRecord[] = [];
const publisher = createTerminalWorkerRecordPublisher((state) => {
if (locked)
throw Object.assign(new Error("reader holds record"), {
code: "EPERM",
});
written.push(state);
});
const pending = publisher.publish(record);
const rejected = expect(pending).rejects.toThrow("stopped");
publisher.stop();
locked = false;
await rejected;
await delay(50);
expect(written).toEqual([]);
await expect(publisher.publish(record)).rejects.toThrow("stopped");
});
it("reports permanent storage failures without retrying", async () => {
const publisher = createTerminalWorkerRecordPublisher(() => {
throw Object.assign(new Error("disk full"), { code: "ENOSPC" });
});
await expect(publisher.publish(record)).rejects.toThrow("disk full");
publisher.stop();
});
});
@@ -1,62 +0,0 @@
import type { TerminalWorkerRecord } from "./terminal-worker-record.js";
const TRANSIENT_ERRORS = new Set(["EPERM", "EBUSY", "EACCES"]);
/** Serializes live worker publication, retaining the latest state across reader contention.
* Creation awaits publication; exit notifications need not wait for a reader to close.
* Stop before retiring the record so a pending retry cannot recreate it.
*/
export function createTerminalWorkerRecordPublisher(write: (record: TerminalWorkerRecord) => void) {
let stopped = false;
let latest: TerminalWorkerRecord;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending:
| {
promise: Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
}
| undefined;
function attempt(): void {
timer = undefined;
const publication = pending;
if (!publication) return;
try {
write(latest);
publication.resolve();
pending = undefined;
} catch (error) {
if (TRANSIENT_ERRORS.has((error as NodeJS.ErrnoException).code ?? "")) {
timer = setTimeout(attempt, 25);
} else {
publication.reject(error);
pending = undefined;
}
}
}
return {
publish(record: TerminalWorkerRecord): Promise<void> {
if (stopped) return Promise.reject(new Error("Worker record publisher stopped"));
latest = record;
if (pending) return pending.promise;
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((yes, no) => {
resolve = yes;
reject = no;
});
pending = { promise, resolve, reject };
attempt();
return promise;
},
stop(): void {
stopped = true;
clearTimeout(timer);
timer = undefined;
pending?.reject(new Error("Worker record publisher stopped"));
pending = undefined;
},
};
}
@@ -2,9 +2,7 @@ import { afterEach, expect, it } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { EventEmitter } from "node:events";
import {
closeSync,
existsSync,
openSync,
mkdirSync,
mkdtempSync,
readdirSync,
@@ -45,7 +43,6 @@ import {
listTerminalWorkerRecords,
readTerminalWorkerRecord,
terminalWorkerDirectory,
terminalWorkerRecordPath,
type TerminalWorkerRecord,
} from "./terminal-worker-record.js";
import { TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV } from "./terminal-worker-protocol.js";
@@ -768,81 +765,52 @@ async function recordedTerminalPids(paseoHome: string): Promise<Map<string, numb
return pids;
}
it.each([false, true])(
"never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record (held reader: %s)",
async (holdReader) => {
const paseoHome = createTemporaryHome();
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-"));
temporaryDirs.push(cwd);
const first = await startRealWorkerManager({ paseoHome });
manager = first;
const exitRequestPath = join(cwd, "exit-requested");
const exitObservedPath = join(cwd, "exit-observed");
const ending = await first.createTerminal({
workspaceId: "ws-unfinished-kill",
cwd,
...nodeTerminalCommand(`
const { existsSync, writeFileSync } = require("node:fs");
setInterval(() => {
if (existsSync(${JSON.stringify(exitRequestPath)})) {
writeFileSync(${JSON.stringify(exitObservedPath)}, "");
process.exit(0);
}
}, 25);
`),
});
const remaining = await first.createTerminal({
workspaceId: "ws-unfinished-kill",
cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
});
const pids = await recordedTerminalPids(paseoHome);
const endingPid = pids.get(ending.id)!;
const remainingPid = pids.get(remaining.id)!;
const [record] = await workerRecords(paseoHome);
it("never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record", async () => {
const paseoHome = createTemporaryHome();
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-"));
temporaryDirs.push(cwd);
const first = await startRealWorkerManager({ paseoHome });
manager = first;
const ending = await first.createTerminal({
workspaceId: "ws-unfinished-kill",
cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
});
const remaining = await first.createTerminal({
workspaceId: "ws-unfinished-kill",
cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
});
const pids = await recordedTerminalPids(paseoHome);
const endingPid = pids.get(ending.id)!;
const remainingPid = pids.get(remaining.id)!;
// A kill cleanup whose process table read fails signals nothing and leaves the worker live.
const inspector = createTerminalWorkerProcessInspector();
await expect(
reapTerminalWorkers({
paseoHome,
inspector: {
...inspector,
listProcesses: () => Promise.reject(new Error("ps timed out")),
},
}),
).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError);
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
// A kill cleanup whose process table read fails signals nothing and leaves the worker live.
const inspector = createTerminalWorkerProcessInspector();
await expect(
reapTerminalWorkers({
paseoHome,
inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) },
}),
).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError);
await first.detach();
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
// Keep an ordinary reader open until the worker delivers the terminal exit.
// Windows cannot replace the record while this handle is open. The exit must
// still reach the controller, and publication must recover after release.
const recordFd = holdReader
? openSync(terminalWorkerRecordPath(paseoHome, record!.pid), "r")
: null;
try {
writeFileSync(exitRequestPath, "");
await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000);
expect(existsSync(exitObservedPath)).toBe(true);
expect(isProcessAlive(endingPid)).toBe(false);
} finally {
if (recordFd !== null) closeSync(recordFd);
}
await waitForCondition(
async () => !(await recordedTerminalPids(paseoHome)).has(ending.id),
10000,
);
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
// One terminal ends on its own: the live worker rewrites its record from memory.
process.kill(endingPid, "SIGKILL");
await waitForCondition(
async () => !(await recordedTerminalPids(paseoHome)).has(ending.id),
10000,
);
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
await first.detach();
manager = await startRealWorkerManager({ paseoHome });
manager = await startRealWorkerManager({ paseoHome });
expect(manager.getTerminal(remaining.id)).toBeUndefined();
expect(await manager.getTerminals(cwd)).toEqual([]);
await waitForCondition(() => !isProcessAlive(remainingPid), 10000);
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
},
);
expect(manager.getTerminal(remaining.id)).toBeUndefined();
expect(await manager.getTerminals(cwd)).toEqual([]);
await waitForCondition(() => !isProcessAlive(remainingPid), 10000);
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
});
// The worker's own `ps`, delayed: a program that exits while the worker reads its birth time
// lands its exit in the middle of creation. Only the worker launched while this PATH is set sees