Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] ea2014e93b fix(terminal): recover worker records after reader contention
Retain the latest record until a transient sharing failure clears. Deliver exits while publication is pending, await publication before confirming creation, and cancel retries before retirement.
2026-10-29 06:05:10 +00:00
paseo-bot[bot] ca9e9a4e39 Merge remote-tracking branch 'origin/ci-janitor/a3a83622' into ci-janitor/pickup-2033b9a3 2026-10-29 06:00:55 +00:00
paseo-bot[bot] 2197805b3d test(terminal): isolate record reader contention on exit 2026-10-29 03:37:17 +00:00
paseo-bot[bot] 117cda20b6 test(terminal): repeat Windows cleanup exit diagnostics 2026-10-29 03:06:42 +00:00
paseo-bot[bot] 94349a133e test(terminal): capture the Windows record rewrite failure 2026-10-29 00:31:37 +00:00
paseo-bot[bot] d33e8f3c08 test(terminal): let cleanup fixture exit on request 2026-10-29 00:06:12 +00:00
44 changed files with 280 additions and 1299 deletions
-4
View File
@@ -95,8 +95,4 @@ 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).
+2 -19
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, symlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -23,7 +23,6 @@ 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.
@@ -117,7 +116,6 @@ const STORES = {
},
google: {
device: "iphone",
androidScreenshots: "phoneScreenshots",
width: 1440,
height: 2560,
textTop: 150,
@@ -136,7 +134,6 @@ const STORES = {
},
"google-tablet": {
device: "ipad",
androidScreenshots: "tenInchScreenshots",
width: 1800,
height: 3200,
textTop: 200,
@@ -355,18 +352,6 @@ 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;
@@ -402,9 +387,7 @@ try {
const marks = await loadAgentMarks(work);
const rows = [];
for (const [name, store] of Object.entries(STORES)) {
const files = await renderStore(browser, name, store, font, marks, work);
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
rows.push(files);
rows.push(await renderStore(browser, name, store, font, marks, work));
}
await renderPreview(rows);
} finally {
+1 -4
View File
@@ -215,8 +215,7 @@ fastlane/metadata/android/
│ ├── images/
│ │ ├── icon.png (512x512)
│ │ ├── featureGraphic.png (1024x500)
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
│ └── changelogs/ (generated — see below)
├── ja/
└── zh-CN/
@@ -224,8 +223,6 @@ 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,15 +44,6 @@ 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).
+3 -10
View File
@@ -30,16 +30,9 @@ 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 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.
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.
## Core adapter patterns
+2 -13
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). 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 (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 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,16 +317,6 @@ 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/`:
@@ -369,7 +359,6 @@ 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 @@
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
../../../../../../packages/website/public/phone-1.png
@@ -1 +1 @@
../../../../../../assets/store-screenshots/output/google/02-chat.png
../../../../../../packages/website/public/phone-2.png
@@ -1 +1 @@
../../../../../../assets/store-screenshots/output/google/03-plugins.png
../../../../../../packages/website/public/phone-3.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google/04-review.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google/05-terminal.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
@@ -42,20 +42,6 @@ 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]
+1 -48
View File
@@ -1,31 +1,7 @@
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
@@ -111,8 +87,7 @@ platform :ios do
},
force: true,
skip_binary_upload: true,
screenshots_path: stage_app_store_screenshots,
overwrite_screenshots: true,
skip_screenshots: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,
@@ -121,25 +96,3 @@ 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,14 +509,10 @@ 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,38 +571,3 @@ 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);
});
+1 -5
View File
@@ -490,11 +490,8 @@ 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.extend({
enforcement: z.enum(["required", "if-supported"]).optional(),
}).optional(),
toolPolicy: ToolPolicySchema.optional(),
systemPrompt: z.string().optional(),
mcpServerRequirements: z.record(z.string(), z.enum(["optional", "required"])).optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
});
@@ -3674,7 +3671,6 @@ 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,7 +19,6 @@ 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";
@@ -446,7 +445,7 @@ class EnvProbeAgentClient extends TestAgentClient {
}
class TestAgentSession implements AgentSession {
readonly provider: AgentProvider;
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private runtimeModel: string | null = null;
@@ -454,9 +453,7 @@ class TestAgentSession implements AgentSession {
private turnIdCounter = 0;
private interrupted = false;
constructor(private readonly config: AgentSessionConfig) {
this.provider = config.provider;
}
constructor(private readonly config: AgentSessionConfig) {}
async run(): Promise<AgentRunResult> {
return {
@@ -3825,85 +3822,54 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in
expect(snapshot.config.mcpServers).toBeUndefined();
});
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);
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);
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 supportsMcp ? new McpCapableTestAgentSession(config) : new TestAgentSession(config);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return new McpCapableTestAgentSession(config);
}
}
const client = new CaptureClient(provider);
const manager = new AgentManager({
clients: {
[provider]: client,
},
providerDefinitions: {
[provider]: {
enabled: true,
applyToolPolicy: buildProviderRegistry(logger)[provider]!.applyToolPolicy,
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",
},
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
},
undefined,
{ workspaceId: undefined },
);
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);
},
);
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
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-"));
@@ -12609,21 +12575,3 @@ 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,8 +216,6 @@ 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;
@@ -1688,7 +1686,7 @@ export class AgentManager {
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
this.hasRequiredExternalMcpServers(providerLaunchConfig) &&
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
existing.session.capabilities.supportsMcpServers !== true
) {
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
@@ -3890,19 +3888,13 @@ 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 (
!this.hasRequiredExternalMcpServers(launchConfig) ||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
session.capabilities.supportsMcpServers === true
) {
return;
@@ -5465,7 +5457,6 @@ 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`,
@@ -5476,16 +5467,6 @@ 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,9 +395,6 @@ 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,7 +607,6 @@ 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,10 +27,6 @@ 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()
@@ -93,7 +89,6 @@ export type SerializableAgentConfig = Pick<
| "toolPolicy"
| "systemPrompt"
| "mcpServers"
| "mcpServerRequirements"
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
@@ -135,29 +135,6 @@ 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,15 +652,12 @@ function createRegistryEntry(
supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval,
applyToolPolicy: (config, toolPolicy) => {
if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) {
if (toolPolicy.enforcement !== "if-supported")
throw new ToolPolicyUnsupportedError(provider);
return { ...config, toolPolicy: undefined };
throw new ToolPolicyUnsupportedError(provider);
}
const exactPolicy = toolPolicy ? { preapproved: toolPolicy.preapproved } : undefined;
return {
...config,
toolPolicy: exactPolicy
? (resolved.contract.applyToolPolicy?.(provider, exactPolicy) ?? exactPolicy)
toolPolicy: toolPolicy
? (resolved.contract.applyToolPolicy?.(provider, toolPolicy) ?? toolPolicy)
: undefined,
};
},
@@ -1,8 +0,0 @@
{"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"}
@@ -1,263 +0,0 @@
{
"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"
}
]
}
@@ -1,8 +0,0 @@
{"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"}
@@ -1,156 +0,0 @@
{
"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"
}
]
}
@@ -1,336 +0,0 @@
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",
},
]);
});
});
@@ -1,119 +0,0 @@
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,6 +1,5 @@
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";
@@ -11,7 +10,6 @@ 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 { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
const BASE_CONFIG: AgentSessionConfig = {
provider: "claude",
@@ -49,15 +49,3 @@ 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,11 +18,6 @@ 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,14 +214,3 @@ 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,7 +78,6 @@ 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,
});
}
@@ -101,7 +100,6 @@ export function buildSessionConfig(
toolPolicy: overrides.toolPolicy,
systemPrompt: overrides.systemPrompt,
mcpServers: overrides.mcpServers,
mcpServerRequirements: overrides.mcpServerRequirements,
});
}
@@ -1966,7 +1966,6 @@ 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,6 +22,7 @@ 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,
@@ -217,13 +218,17 @@ function runWorker(argv: string[]): void {
controller?.send(message);
}
function persistRecord(): void {
const recordPublisher = createTerminalWorkerRecordPublisher((state) => {
writeTerminalWorkerRecord(args.paseoHome, state);
});
async function persistRecord(): Promise<void> {
if (!record || exiting) return;
record = {
...record,
terminals: record.terminals.filter((entry) => sessionsById.has(entry.id)),
};
writeTerminalWorkerRecord(args.paseoHome, record);
await recordPublisher.publish(record);
}
function removeOwnFiles(): void {
@@ -236,6 +241,7 @@ function runWorker(argv: string[]): void {
function exitWorker(code: number): void {
if (exiting) return;
exiting = true;
recordPublisher.stop();
removeOwnFiles();
server?.close();
process.exit(code);
@@ -364,7 +370,9 @@ function runWorker(argv: string[]): void {
outputCoalescer.flush();
clearTerminalSubscriptions(session.id);
sessionsById.delete(session.id);
persistRecord();
void persistRecord().catch((error) => {
if (!exiting) console.error("Cannot publish terminal exit:", error);
});
if (exitsHeldDuringCreation.has(session.id)) {
exitsHeldDuringCreation.set(session.id, info);
return;
@@ -439,7 +447,7 @@ function runWorker(argv: string[]): void {
{ id: session.id, pid, startedAt: inspection.snapshot.startedAt },
],
};
persistRecord();
await persistRecord();
}
function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise<void> {
@@ -0,0 +1,70 @@
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();
});
});
@@ -0,0 +1,62 @@
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,7 +2,9 @@ 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,
@@ -43,6 +45,7 @@ import {
listTerminalWorkerRecords,
readTerminalWorkerRecord,
terminalWorkerDirectory,
terminalWorkerRecordPath,
type TerminalWorkerRecord,
} from "./terminal-worker-record.js";
import { TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV } from "./terminal-worker-protocol.js";
@@ -765,52 +768,81 @@ async function recordedTerminalPids(paseoHome: string): Promise<Map<string, numb
return pids;
}
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)!;
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);
// 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);
// 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);
// 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);
// 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);
manager = await startRealWorkerManager({ paseoHome });
await first.detach();
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