Added nono profiles & orca integration - Added some extensions to start working on

This commit is contained in:
jb
2026-08-24 10:23:38 -04:00
parent 8bcb82813c
commit 10f64b593b
83 changed files with 21840 additions and 1353 deletions
+3 -1
View File
@@ -2,4 +2,6 @@ node_modules/
coverage/
dist/
*.log
.DS_Store
.DS_Store
.mesh
.pi
+4
View File
@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this .gitignore file
!.gitignore
+9 -3
View File
@@ -10,11 +10,17 @@ Personal extensions and skills for [Pi](https://github.com/earendil-works/pi-mon
## Repository layout
```text
extensions/ Pi extensions
skills/ Pi skills (one directory with a SKILL.md per skill)
docs/ Usage guides
extensions/ Pi extensions
profiles/nono/ Repo-managed nono profiles, symlinked into ~/.config/nono/profiles
skills/ Pi skills (one directory with a SKILL.md per skill)
```
The root `package.json` exposes both directories as a single Pi package.
The root `package.json` exposes the extensions and skills as a single Pi package.
## Nono sandbox
See [`docs/nono.md`](docs/nono.md) for `pi-local` usage, profile settings, Internet tiers, multi-repository access, and the `nono-profiles` editing skill.
## Install locally
+313
View File
@@ -0,0 +1,313 @@
# Nono profiles
This repository stores local nono profiles under [`profiles/nono/`](../profiles/nono/). The active nono directory contains one symlink per profile:
```text
~/.config/nono/profiles/pi-local.json
-> /srv/data/code/harness-tools/profiles/nono/pi-local.json
```
Keeping the JSON in Git makes profile changes reviewable. The symlink lets nono load the repository copy by profile name.
## Run Pi with nono
Launch Pi from the repository you want to edit:
```bash
cd /path/to/project
nono run --profile pi-local -- pi
```
`pi-local` gives the launch working directory read/write access. A session that needs several repositories also needs explicit paths in the profile.
Check which profiles nono can load:
```bash
nono profile list
nono profile validate pi-local
```
Inspect a denied operation from inside a sandbox:
```bash
nono why --self --path /resolved/path --op read
nono why --self --path /resolved/path --op write
nono why --self --path /resolved/path --op readwrite
```
A profile change affects the next sandbox launch. It cannot expand the permissions of the current process.
## Orca Pi runner routing
Orca launches Pi through [`scripts/pi-orca.fish`](../scripts/pi-orca.fish). The runner resolves the canonical Git project through [`scripts/orca-nono-profile.py`](../scripts/orca-nono-profile.py), then selects the nono profile from [`profiles/orca/project-nono-profiles.json`](../profiles/orca/project-nono-profiles.json).
The runner treats the runtime, sandbox, and Pi agent directory as one configuration:
| Route | Nono profile | Node runtime | Pi agent directory |
| --- | --- | --- | --- |
| Default | `pi-orca-local` | system Node 26 | `~/.pi/agent` |
| `/srv/work` descendants | `pi-work` | fnm Node 22.23.2 | `~/.pi/profiles/cyone` |
The separate Pi agent directories are required because native extension dependencies such as `better-sqlite3` are compiled for one Node ABI. Never point Node 22 and Node 26 runners at the same `PI_CODING_AGENT_DIR`.
Exact canonical-project mappings override root mappings. Root mappings use the longest matching ancestor of either the canonical Git root or the actual launch path. This keeps linked worktrees launched below `/srv/work` on the work stack even when their main checkout lives elsewhere, while `/srv/work-other` does not match `/srv/work`.
```bash
orca-nono-profile show /srv/work/code/example
orca-nono-profile set pi-work /srv/work/code/example
orca-nono-profile unset /srv/work/code/example
orca-nono-profile set-root pi-work /srv/work
orca-nono-profile unset-root /srv/work
```
After changing a route, Node runtime, or Pi profile, restart the Orca terminal. Existing Pi processes cannot switch runtimes or agent directories. The runner fails closed when its trusted resolver or resolved nono profile is unavailable; it never falls back from a work route to the default Node 26 state.
## Edit profiles with the skill
The repository skill lives at [`skills/tools/nono-profiles/`](../skills/tools/nono-profiles/). Pi invokes it when you ask to create or edit a nono profile, change sandbox directories, select an Internet tier, add domains, configure sockets, or troubleshoot profile access.
Example requests:
- “Update my nono profile so this session can edit three client repositories.”
- “Create a project profile with npm, GitHub, and the staging API allowlisted.”
- “Make an offline profile for this repository.”
- “Give this profile read-only access to a shared documentation checkout.”
The skill asks whether to change `pi-local` or create a derived profile. It then collects exact paths, access levels, an Internet tier, and any required domains. It shows the resolved policy and a diff before writing the repository profile.
The skill stages candidates under `~/.config/nono/profile-drafts/` and validates them in strict mode. It does not edit `~/.config/nono/profiles/` from inside a sandbox. When a new profile needs activation, run the supplied symlink command outside that sandbox.
## Current `pi-local` baseline
[`profiles/nono/pi-local.json`](../profiles/nono/pi-local.json) extends the registry-managed `nolabs-ai/pi` profile. It adds:
- read/write access to the launch working directory and temporary directory
- read-only access to local Pi extension source and `$HOME/.bun`; the Orca runner invokes the selected Node executable explicitly rather than relying on `PATH`
- pathname-mediated access to the system resolver Unix socket on Linux
- an Internet hostname allowlist for model providers, source hosts, package registries, and toolchain downloads
The parent profile contributes more policy, including Pi state access, security groups, credential route definitions, URL-opening origins, localhost URL opening, and service launching. Inspect the resolved profile before treating a child as narrower than its parent:
```bash
nono profile show pi-local
```
## Profile settings
### `extends`
`extends` names one parent profile:
```json
{
"extends": "nolabs-ai/pi"
}
```
Inheritance is transitive. List-valued capabilities and included groups are generally additive. An empty child list does not reliably remove a parent capability, so choose a narrower parent when the child needs fewer permissions.
Use `pi-local` as the parent when its existing filesystem and domain permissions are acceptable. Use `nolabs-ai/pi` when building an offline profile, a smaller domain allowlist, or an open-network profile without inheriting `pi-local`'s hostname list.
### `meta`
Give each profile a stable command-line name and a short description:
```json
{
"meta": {
"name": "pi-client-a",
"description": "Pi access for Client A repositories and development services"
}
}
```
Keep the filename aligned with the name: `profiles/nono/pi-client-a.json`.
### `groups.include`
`groups.include` adds reusable policy groups defined by built-in or package profiles. Group entries may include platform conditions. Treat groups as capabilities inherited from the parent; an empty list does not remove parent groups.
### `workdir.access`
The working directory is the directory where you launch nono, not the directory containing the profile.
```json
{
"workdir": {
"access": "readwrite"
}
}
```
Launch from the project root when one repository needs write access. Add explicit filesystem entries for additional repositories.
### Filesystem directories
Directory fields apply recursively:
| Field | Access |
| --- | --- |
| `filesystem.read` | Read-only |
| `filesystem.write` | Write-only |
| `filesystem.allow` | Read/write |
A multi-repository profile can enumerate repositories:
```json
{
"filesystem": {
"allow": [
"/srv/data/code/client-a/frontend",
"/srv/data/code/client-a/backend"
],
"read": [
"/srv/data/code/shared-docs"
]
}
}
```
Enumerating repositories limits the profile's reach. Granting `/srv/data/code/client-a` gives the selected access to every current and future child directory, which may suit a dedicated client workspace but carries a larger blast radius.
Resolve symlinks before adding paths. nono may enforce access against the canonical target.
### Exact files
Use exact-file fields when the session needs one file rather than its directory:
| Field | Access |
| --- | --- |
| `filesystem.read_file` | Read-only |
| `filesystem.write_file` | Write-only |
| `filesystem.allow_file` | Read/write |
### Temporary files
`$TMPDIR` under `filesystem.allow` permits temporary-file creation. Keep it when tools need scratch files. Remove it only after checking the tools used by that profile.
### Unix sockets
`filesystem.unix_socket` lists local socket paths. Socket access is separate from Internet access:
```json
{
"filesystem": {
"unix_socket": [
"/run/systemd/resolve/io.systemd.Resolve"
]
}
}
```
Add exact sockets for required local services. Avoid granting a socket directory.
`linux.af_unix_mediation: "pathname"` enables pathname-based Unix-socket mediation on Linux.
### Network access
`network.block` and `network.allow_domain` define three useful tiers.
#### Offline
Derive from `nolabs-ai/pi`, block networking, and clear inherited host-opening capabilities:
```json
{
"extends": "nolabs-ai/pi",
"network": {
"block": true
},
"open_urls": {
"allow_origins": [],
"allow_localhost": false
},
"allow_launch_services": false
}
```
Do not derive this tier from allowlisted `pi-local`. Its inherited proxy domains conflict with blocked networking.
#### Allowlisted
Enable networking and list exact hostnames:
```json
{
"network": {
"block": false,
"allow_domain": [
"git.example.com",
"registry.npmjs.org",
"api.staging.example.com"
]
}
}
```
Entries are hostnames, not full URLs or path patterns. Include artifact hosts and CDNs used by package managers, not only their metadata endpoint. Start with the operations the project performs and add the hosts those operations require.
A child of `pi-local` inherits its existing domains. Derive from `nolabs-ai/pi` when the project needs a smaller or different list.
#### Open
Enable networking without an inherited domain allowlist:
```json
{
"extends": "nolabs-ai/pi",
"network": {
"block": false
}
}
```
Open mode permits destinations beyond the hosts currently known to the project. Review the parent's URL-opening and credential settings separately.
### URL opening
`open_urls.allow_origins` lists origins that sandboxed tools may ask the host to open. `open_urls.allow_localhost` controls localhost origins. These settings do not replace `network.allow_domain`.
The `nolabs-ai/pi` parent grants selected authentication and source-host origins plus localhost. Keep, narrow, or clear them according to the project.
### Service launching
`allow_launch_services` controls service launching on supported platforms. Set it to `false` for offline profiles. Review its inherited value for the other network tiers.
### Credential routes
Credential routes let nono mediate secrets and restrict upstream endpoints. Network access to a host and access to a credential route are separate permissions. Prefer mediated routes over storing API keys where the sandboxed process can read them.
Check the installed Pi package guide before enabling a route:
```text
~/.config/nono/packages/nolabs-ai/pi/README.md
```
## Safe change workflow
1. Create `~/.config/nono/profile-drafts/<name>.json`.
2. Validate and inspect the resolved policy:
```bash
nono profile validate --draft <name> --strict
nono profile show ~/.config/nono/profile-drafts/<name>.json
```
3. Review the parent, filesystem capabilities, Internet tier, domains, URL-opening origins, service launching, sockets, and credential routes.
4. Copy the approved draft to `profiles/nono/<name>.json`.
5. Validate the repository copy:
```bash
nono profile validate --strict profiles/nono/<name>.json
```
6. For a new repository-managed profile, create its active symlink outside the sandbox:
```bash
ln -sfn /absolute/repo/profiles/nono/<name>.json ~/.config/nono/profiles/<name>.json
nono profile validate <name>
```
7. Start a new nono session with the profile.
Use `nono profile promote <name>` only when you want a regular active profile instead of a repository symlink. Replacing an existing regular profile requires nono's draft base-hash guard; use nono's edit workflow rather than bypassing that check.
+61
View File
@@ -0,0 +1,61 @@
import {
copyToClipboard,
type ExtensionAPI,
} from "@earendil-works/pi-coding-agent";
function textFromContent(content: unknown) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((block) => {
if (!block || typeof block !== "object") return "";
if (!("type" in block)) return "";
if (
block.type === "text" &&
"text" in block &&
typeof block.text === "string"
) {
return block.text;
}
if (block.type === "image") return "[image]";
return "";
})
.filter(Boolean)
.join("\n");
}
export default function (pi: ExtensionAPI) {
pi.registerCommand("copy-all", {
description:
"Copy all previous user and assistant messages in this thread to the clipboard",
handler: async (_args, ctx) => {
await ctx.waitForIdle();
const sections = ctx.sessionManager
.getBranch()
.filter((entry) => entry.type === "message")
.map((entry) => entry.message)
.filter(
(message) => message.role === "user" || message.role === "assistant",
)
.map((message) => ({
role: message.role,
content: textFromContent(message.content).trim(),
}))
.filter(({ content }) => content)
.map(({ role, content }) => `${role.toUpperCase()}:\n${content}`);
if (sections.length === 0) {
ctx.ui.notify("No user or assistant messages to copy", "info");
return;
}
await copyToClipboard(sections.join("\n\n---\n\n"));
ctx.ui.notify(`Copied ${sections.length} messages to clipboard`, "info");
},
});
}
+443
View File
@@ -0,0 +1,443 @@
import { NodeServices } from "@effect/platform-node";
import { assert, it } from "@effect/vitest";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { Effect, FileSystem } from "effect";
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import {
buildFdArgs,
buildRgArgs,
FD_DEFAULT_LIMIT,
normalizeSearchPath,
} from "./src/args.ts";
import {
FD_INTEL_DARWIN_VERSION,
InstallError,
readBoundedResponse,
releaseAsset,
resolveBinary,
TOOL_SPECS,
UnsupportedPlatformError,
type BinaryEnv,
type ReleaseAsset,
type ResolvedBinary,
} from "./src/binaries.ts";
import { formatCapturedOutput, formatOutput } from "./src/output.ts";
import { executeSearchProcess } from "./src/process.ts";
import { installNotifications, makeBinaryInitializers } from "./index.ts";
// --- argument construction -------------------------------------------------
it("fd args: defaults list everything with the default limit", () => {
assert.deepEqual(buildFdArgs({}), [
"--color=never",
"--max-results",
String(FD_DEFAULT_LIMIT),
"--",
"",
]);
});
it("fd args: all options are translated and pattern stays behind --", () => {
const args = buildFdArgs({
pattern: "-rf",
path: "@src",
type: "file",
extension: ".ts",
glob: true,
hidden: true,
max_depth: 3,
limit: 50,
});
assert.deepEqual(args, [
"--color=never",
"--hidden",
"--glob",
"--type",
"f",
"--extension",
"ts",
"--max-depth",
"3",
"--max-results",
"50",
"--",
"-rf",
"src",
]);
});
it("fd args: out-of-range values are clamped", () => {
const args = buildFdArgs({ max_depth: 500, limit: 1_000_000 });
assert.deepEqual(args, [
"--color=never",
"--max-depth",
"64",
"--max-results",
"10000",
"--",
"",
]);
});
it("rg args: defaults use smart-case and safe separators", () => {
assert.deepEqual(buildRgArgs({ pattern: "--help" }), [
"--line-number",
"--color=never",
"--no-heading",
"--with-filename",
"--smart-case",
"--max-count",
"100",
"--",
"--help",
]);
});
it("rg args: all options are translated", () => {
const args = buildRgArgs({
pattern: "TODO",
path: "@lib",
glob: "*.ts",
file_type: "ts",
case_sensitive: true,
fixed_strings: true,
hidden: true,
context: 2,
limit: 10,
});
assert.deepEqual(args, [
"--line-number",
"--color=never",
"--no-heading",
"--with-filename",
"--case-sensitive",
"--fixed-strings",
"--hidden",
"--context",
"2",
"--glob",
"*.ts",
"--type",
"ts",
"--max-count",
"10",
"--",
"TODO",
"lib",
]);
});
it("rg args: case_sensitive false forces ignore-case", () => {
const args = buildRgArgs({ pattern: "x", case_sensitive: false });
assert.isTrue(args.includes("--ignore-case"));
assert.isFalse(args.includes("--smart-case"));
});
it("path normalization strips leading @ and expands ~", () => {
assert.equal(normalizeSearchPath("@src/lib"), "src/lib");
assert.equal(normalizeSearchPath("~"), homedir());
assert.equal(normalizeSearchPath("~/projects"), join(homedir(), "projects"));
assert.equal(normalizeSearchPath(" plain "), "plain");
});
// --- binary resolution -----------------------------------------------------
function makeEnv(options: {
available?: string[];
installShouldFail?: boolean;
}): BinaryEnv & { installs: ReleaseAsset[]; probes: string[] } {
const installs: ReleaseAsset[] = [];
const probes: string[] = [];
const installed = new Set<string>();
return {
installs,
probes,
probe: (command) =>
Effect.sync(() => {
probes.push(command);
return (
(options.available ?? []).includes(command) || installed.has(command)
);
}),
install: (asset, destination) => {
if (options.installShouldFail) {
return Effect.fail(new InstallError({ message: "network down" }));
}
return Effect.sync(() => {
installs.push(asset);
installed.add(destination);
});
},
};
}
const darwinArm = { os: "darwin", arch: "arm64" } as const;
it.effect("binary resolution: system fd wins and nothing is installed", () =>
Effect.gen(function* () {
const env = makeEnv({ available: ["fd"] });
const resolved = yield* resolveBinary(
TOOL_SPECS.fd,
"/repo/bin",
darwinArm,
env,
);
assert.deepEqual(resolved, {
tool: "fd",
command: "fd",
source: "system",
});
assert.equal(env.installs.length, 0);
}),
);
it.effect("binary resolution: fdfind is accepted as a system fd", () =>
Effect.gen(function* () {
const env = makeEnv({ available: ["fdfind"] });
const resolved = yield* resolveBinary(
TOOL_SPECS.fd,
"/repo/bin",
darwinArm,
env,
);
assert.deepEqual(resolved, {
tool: "fd",
command: "fdfind",
source: "system",
});
assert.equal(env.installs.length, 0);
}),
);
it.effect("binary resolution: existing bin fallback is used silently", () =>
Effect.gen(function* () {
const env = makeEnv({ available: ["/repo/bin/rg"] });
const resolved = yield* resolveBinary(
TOOL_SPECS.rg,
"/repo/bin",
darwinArm,
env,
);
assert.deepEqual(resolved, {
tool: "rg",
command: "/repo/bin/rg",
source: "bundled",
});
assert.equal(env.installs.length, 0);
}),
);
it.effect(
"binary resolution: missing everywhere triggers exactly one install",
() =>
Effect.gen(function* () {
const env = makeEnv({ available: [] });
const resolved = yield* resolveBinary(
TOOL_SPECS.rg,
"/repo/bin",
darwinArm,
env,
);
assert.equal(resolved.source, "installed");
assert.equal(resolved.command, "/repo/bin/rg");
assert.equal(env.installs.length, 1);
assert.match(
env.installs[0].url,
/^https:\/\/github\.com\/BurntSushi\/ripgrep\//,
);
}),
);
it.effect("binary resolution: install failure surfaces a typed error", () =>
Effect.gen(function* () {
const env = makeEnv({ available: [], installShouldFail: true });
const error = yield* Effect.flip(
resolveBinary(TOOL_SPECS.fd, "/repo/bin", darwinArm, env),
);
assert.instanceOf(error, InstallError);
assert.equal(error.message, "network down");
}),
);
it.effect(
"binary resolution: unsupported platform fails without installing",
() =>
Effect.gen(function* () {
const env = makeEnv({ available: [] });
const error = yield* Effect.flip(
resolveBinary(
TOOL_SPECS.fd,
"/repo/bin",
{ os: "linux", arch: "s390x" },
env,
),
);
assert.instanceOf(error, UnsupportedPlatformError);
assert.equal(env.installs.length, 0);
}),
);
it.effect("binary resolution: one failed tool does not disable the other", () =>
Effect.gen(function* () {
const env = makeEnv({
available: ["rg"],
installShouldFail: true,
});
const initializers = makeBinaryInitializers("/repo/bin", darwinArm, env);
const fdError = yield* Effect.flip(initializers.fd);
const rg = yield* initializers.rg;
assert.instanceOf(fdError, InstallError);
assert.deepEqual(rg, { tool: "rg", command: "rg", source: "system" });
}),
);
it("release assets cover macOS and Linux on arm64 and x64 over HTTPS", () => {
for (const os of ["darwin", "linux"] as const) {
for (const arch of ["arm64", "x64"] as const) {
for (const tool of ["fd", "rg"] as const) {
const asset = releaseAsset(tool, { os, arch });
assert.isDefined(asset, `${tool} ${os}/${arch}`);
assert.match(asset.url, /^https:\/\//);
assert.isTrue(asset.url.endsWith(asset.fileName));
assert.match(asset.sha256, /^[a-f0-9]{64}$/);
}
}
}
});
it("linux assets use statically linked musl builds", () => {
const asset = releaseAsset("fd", { os: "linux", arch: "x64" });
assert.isTrue(asset?.url.includes("unknown-linux-musl"));
});
it("Intel macOS uses the latest fd release that publishes that target", () => {
const asset = releaseAsset("fd", { os: "darwin", arch: "x64" });
assert.equal(asset?.version, FD_INTEL_DARWIN_VERSION);
});
it.effect(
"bounded downloads reject oversized declared and streamed bodies",
() =>
Effect.gen(function* () {
const request = HttpClientRequest.get(
"https://example.com/archive.tar.gz",
);
const declared = HttpClientResponse.fromWeb(
request,
new Response("small", {
headers: { "content-length": "100" },
}),
);
const declaredError = yield* Effect.flip(
readBoundedResponse(declared, 10),
);
assert.match(declaredError.message, /size limit/);
const streamed = HttpClientResponse.fromWeb(
request,
new Response("this body is too large"),
);
const streamedError = yield* Effect.flip(
readBoundedResponse(streamed, 5),
);
assert.match(streamedError.message, /size limit/);
}),
);
// --- notification policy ----------------------------------------------------
it("notifications: only fresh installs notify", () => {
const system: ResolvedBinary = {
tool: "fd",
command: "fd",
source: "system",
};
const bundled: ResolvedBinary = {
tool: "rg",
command: "/repo/bin/rg",
source: "bundled",
};
const installed: ResolvedBinary = {
tool: "rg",
command: "/repo/bin/rg",
source: "installed",
version: "15.2.0",
};
assert.deepEqual(installNotifications([system, bundled]), []);
const messages = installNotifications([system, installed]);
assert.equal(messages.length, 1);
assert.match(messages[0], /downloaded rg 15\.2\.0/);
});
// --- output truncation -------------------------------------------------------
it.effect("process output is streamed to a complete spill file", () =>
Effect.gen(function* () {
const result = yield* executeSearchProcess({
command: process.execPath,
args: ["-e", 'process.stdout.write("line\\n".repeat(3000))'],
cwd: process.cwd(),
tempPrefix: "pi-search-test-",
});
const formatted = formatCapturedOutput(result.output);
assert.equal(result.code, 0);
assert.isTrue(formatted.truncated);
assert.equal(formatted.lineCount, 3000);
assert.match(formatted.text, /2000 of 3000 lines/);
assert.isDefined(formatted.fullOutputPath);
const fs = yield* FileSystem.FileSystem;
const fullOutput = yield* fs.readFileString(formatted.fullOutputPath);
assert.equal(fullOutput, "line\n".repeat(3000));
yield* fs.remove(dirname(formatted.fullOutputPath), {
recursive: true,
force: true,
});
}).pipe(Effect.provide(NodeServices.layer)),
);
it("output: small results pass through untouched", async () => {
const formatted = await formatOutput("a.ts\nb.ts\n", {
tempPrefix: "pi-fd-",
persistFullOutput: () => Promise.reject(new Error("should not persist")),
});
assert.equal(formatted.text, "a.ts\nb.ts");
assert.equal(formatted.lineCount, 2);
assert.isFalse(formatted.truncated);
assert.isUndefined(formatted.fullOutputPath);
});
it("output: oversized results are truncated and persisted", async () => {
const bigOutput = Array.from({ length: 3000 }, (_, i) => `file-${i}.ts`).join(
"\n",
);
let persisted: string | undefined;
const formatted = await formatOutput(bigOutput, {
tempPrefix: "pi-fd-",
persistFullOutput: async (full) => {
persisted = full;
return "/tmp/fake/output.txt";
},
});
assert.isTrue(formatted.truncated);
assert.equal(formatted.fullOutputPath, "/tmp/fake/output.txt");
assert.equal(persisted, bigOutput);
assert.match(formatted.text, /\[Output truncated: 2000 of 3000 lines/);
assert.match(
formatted.text,
/Full output saved to: \/tmp\/fake\/output\.txt\]/,
);
const shownLines = formatted.text.split("\n");
assert.equal(shownLines[0], "file-0.ts");
});
+456
View File
@@ -0,0 +1,456 @@
/**
* file-search — first-class `fd` and `rg` tools for pi.
*
* On session start the extension resolves a usable binary for each tool:
* a normally installed system binary is preferred (silently), then an
* existing fallback in this repo's `bin/` directory (silently), and only
* when neither exists is an official release downloaded into `bin/` — the
* single case that shows a UI notification. Tools await that initialization
* before executing, and report a clear error if it failed.
*/
import { NodeServices } from "@effect/platform-node";
import type {
AgentToolResult,
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Cause, Data, Effect, Exit } from "effect";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import {
buildFdArgs,
buildRgArgs,
FD_MAX_DEPTH_LIMIT,
FD_MAX_LIMIT,
RG_MAX_CONTEXT,
RG_MAX_COUNT_LIMIT,
} from "./src/args.ts";
import {
currentTarget,
liveBinaryEnv,
repositoryBinDir,
resolveBinary,
TOOL_SPECS,
type BinaryEnv,
type BinarySource,
type PlatformTarget,
type ResolvedBinary,
} from "./src/binaries.ts";
import { formatCapturedOutput, type CapturedOutput } from "./src/output.ts";
import {
FD_PARAMETER_DESCRIPTIONS,
FD_PROMPT_GUIDELINES,
FD_PROMPT_SNIPPET,
FD_TOOL_DESCRIPTION,
RG_PARAMETER_DESCRIPTIONS,
RG_PROMPT_GUIDELINES,
RG_PROMPT_SNIPPET,
RG_TOOL_DESCRIPTION,
} from "./src/prompt.ts";
import { discardCapturedOutput, executeSearchProcess } from "./src/process.ts";
export function makeBinaryInitializers(
binDir: string,
target: PlatformTarget,
env: BinaryEnv,
) {
return {
fd: Effect.runSync(
Effect.cached(resolveBinary(TOOL_SPECS.fd, binDir, target, env)),
),
rg: Effect.runSync(
Effect.cached(resolveBinary(TOOL_SPECS.rg, binDir, target, env)),
),
};
}
/** Human-readable install notice, shown only for fresh downloads. */
export function installNotifications(binaries: readonly ResolvedBinary[]) {
return binaries
.filter((binary) => binary.source === "installed")
.map(
(binary) =>
`file-search: no system ${binary.tool} found — downloaded ${binary.tool} ${binary.version ?? ""}`.trimEnd() +
` to ${repositoryBinDir()}`,
);
}
class SearchError extends Data.TaggedError("SearchError")<{
readonly message: string;
}> {}
interface SearchOutcome {
readonly output: CapturedOutput;
readonly noMatches: boolean;
readonly binarySource: BinarySource;
}
export interface FdToolDetails {
readonly binarySource: BinarySource;
readonly matchCount: number;
readonly truncated: boolean;
readonly fullOutputPath?: string;
}
export interface RgToolDetails {
readonly binarySource: BinarySource;
readonly outputLines: number;
readonly truncated: boolean;
readonly fullOutputPath?: string;
}
const EXEC_TIMEOUT_MS = 60_000;
function causeMessage<E>(cause: Cause.Cause<E>) {
const [first] = Cause.prettyErrors(cause);
return first?.message ?? Cause.pretty(cause);
}
function unwrapToolExit<A, E>(exit: Exit.Exit<A, E>, tool: "fd" | "rg") {
if (Exit.isSuccess(exit)) return exit.value;
if (Cause.hasInterruptsOnly(exit.cause)) {
throw new Error(`${tool} search was cancelled.`);
}
throw new Error(causeMessage(exit.cause));
}
export default function fileSearchTools(pi: ExtensionAPI) {
let notified = false;
const binDir = repositoryBinDir();
const target = currentTarget();
const initializers = makeBinaryInitializers(binDir, target, liveBinaryEnv);
pi.on("session_start", async (_event, ctx) => {
const exit = await Effect.runPromiseExit(
Effect.gen(function* () {
const initialized = yield* Effect.all(
{
fd: Effect.exit(initializers.fd),
rg: Effect.exit(initializers.rg),
},
{ concurrency: "unbounded" },
);
if (!ctx.hasUI || notified) return;
notified = true;
for (const tool of ["fd", "rg"] as const) {
const toolExit = initialized[tool];
if (Exit.isSuccess(toolExit)) {
for (const message of installNotifications([toolExit.value])) {
ctx.ui.notify(message, "info");
}
} else {
ctx.ui.notify(
`file-search ${tool} setup failed: ${causeMessage(toolExit.cause)}`,
"error",
);
}
}
}),
);
if (Exit.isFailure(exit) && ctx.hasUI && !notified) {
notified = true;
ctx.ui.notify(
`file-search setup failed: ${causeMessage(exit.cause)}`,
"error",
);
}
});
/** Await init, stream the binary output to disk, and classify its exit. */
function runSearch(tool: "fd" | "rg", args: string[], ctx: ExtensionContext) {
return Effect.gen(function* () {
const binary = yield* initializers[tool];
const result = yield* executeSearchProcess({
command: binary.command,
args,
cwd: ctx.cwd,
tempPrefix: `pi-${tool}-`,
});
// ripgrep exits 1 for "no matches"; fd exits 0 even with no results.
if (tool === "rg" && result.code === 1 && result.output.lineCount === 0) {
return {
output: result.output,
noMatches: true,
binarySource: binary.source,
} satisfies SearchOutcome;
}
if (result.code !== 0) {
yield* discardCapturedOutput(result.output);
const detail = result.stderr.trim() || `exit code ${result.code}`;
return yield* new SearchError({ message: `${tool} failed: ${detail}` });
}
return {
output: result.output,
noMatches: result.output.lineCount === 0,
binarySource: binary.source,
} satisfies SearchOutcome;
}).pipe(
Effect.timeout(EXEC_TIMEOUT_MS),
Effect.mapError((error) => {
if (error instanceof SearchError) return error;
return new SearchError({
message:
error._tag === "TimeoutError"
? `${tool} timed out.`
: error instanceof Error
? error.message
: String(error),
});
}),
Effect.provide(NodeServices.layer),
);
}
pi.registerTool<ReturnType<typeof fdParameters>, FdToolDetails>({
name: "fd",
label: "Find Files",
description: FD_TOOL_DESCRIPTION,
promptSnippet: FD_PROMPT_SNIPPET,
promptGuidelines: FD_PROMPT_GUIDELINES,
parameters: fdParameters(),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const exit = await Effect.runPromiseExit(
Effect.gen(function* () {
const outcome = yield* runSearch("fd", buildFdArgs(params), ctx);
if (outcome.noMatches) {
return {
content: [{ type: "text", text: "No files found" }],
details: {
binarySource: outcome.binarySource,
matchCount: 0,
truncated: false,
},
} satisfies AgentToolResult<FdToolDetails>;
}
const formatted = formatCapturedOutput(outcome.output);
return {
content: [{ type: "text", text: formatted.text }],
details: {
binarySource: outcome.binarySource,
matchCount: formatted.lineCount,
truncated: formatted.truncated,
fullOutputPath: formatted.fullOutputPath,
},
} satisfies AgentToolResult<FdToolDetails>;
}),
signal ? { signal } : undefined,
);
return unwrapToolExit(exit, "fd");
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("fd "));
text += theme.fg("accent", args.pattern ? `"${args.pattern}"` : "(all)");
if (args.path) text += theme.fg("muted", ` in ${args.path}`);
const flags = [
args.type && `type=${args.type}`,
args.extension && `ext=${args.extension}`,
args.glob && "glob",
args.hidden && "hidden",
args.max_depth !== undefined && `depth≤${args.max_depth}`,
].filter((flag): flag is string => typeof flag === "string");
if (flags.length > 0) text += " " + theme.fg("dim", flags.join(" "));
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
const details = result.details;
if (!details || details.matchCount === 0) {
return new Text(theme.fg("dim", "No files found"), 0, 0);
}
let text = theme.fg(
"success",
`${details.matchCount} ${details.matchCount === 1 ? "entry" : "entries"}`,
);
if (details.truncated) text += theme.fg("warning", " (truncated)");
if (expanded)
text += expandedPreview(result, details.fullOutputPath, theme);
return new Text(text, 0, 0);
},
});
pi.registerTool<ReturnType<typeof rgParameters>, RgToolDetails>({
name: "rg",
label: "Search Content",
description: RG_TOOL_DESCRIPTION,
promptSnippet: RG_PROMPT_SNIPPET,
promptGuidelines: RG_PROMPT_GUIDELINES,
parameters: rgParameters(),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const exit = await Effect.runPromiseExit(
Effect.gen(function* () {
const outcome = yield* runSearch("rg", buildRgArgs(params), ctx);
if (outcome.noMatches) {
return {
content: [{ type: "text", text: "No matches found" }],
details: {
binarySource: outcome.binarySource,
outputLines: 0,
truncated: false,
},
} satisfies AgentToolResult<RgToolDetails>;
}
const formatted = formatCapturedOutput(outcome.output);
return {
content: [{ type: "text", text: formatted.text }],
details: {
binarySource: outcome.binarySource,
outputLines: formatted.lineCount,
truncated: formatted.truncated,
fullOutputPath: formatted.fullOutputPath,
},
} satisfies AgentToolResult<RgToolDetails>;
}),
signal ? { signal } : undefined,
);
return unwrapToolExit(exit, "rg");
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("rg "));
text += theme.fg("accent", `"${args.pattern}"`);
if (args.path) text += theme.fg("muted", ` in ${args.path}`);
const flags = [
args.glob && `glob=${args.glob}`,
args.file_type && `type=${args.file_type}`,
args.fixed_strings && "literal",
args.hidden && "hidden",
args.context !== undefined && `ctx=${args.context}`,
].filter((flag): flag is string => typeof flag === "string");
if (flags.length > 0) text += " " + theme.fg("dim", flags.join(" "));
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
const details = result.details;
if (!details || details.outputLines === 0) {
return new Text(theme.fg("dim", "No matches found"), 0, 0);
}
let text = theme.fg(
"success",
`${details.outputLines} output ${details.outputLines === 1 ? "line" : "lines"}`,
);
if (details.truncated) text += theme.fg("warning", " (truncated)");
if (expanded)
text += expandedPreview(result, details.fullOutputPath, theme);
return new Text(text, 0, 0);
},
});
}
const PREVIEW_LINES = 20;
interface ThemeLike {
fg(color: string, text: string): string;
}
function expandedPreview(
result: { content: { type: string; text?: string }[] },
fullOutputPath: string | undefined,
theme: ThemeLike,
) {
let text = "";
const content = result.content[0];
if (content?.type === "text" && content.text) {
const lines = content.text.split("\n");
for (const line of lines.slice(0, PREVIEW_LINES)) {
text += `\n${theme.fg("dim", line)}`;
}
if (lines.length > PREVIEW_LINES) {
text += `\n${theme.fg("muted", `... ${lines.length - PREVIEW_LINES} more lines`)}`;
}
}
if (fullOutputPath) {
text += `\n${theme.fg("dim", `Full output: ${fullOutputPath}`)}`;
}
return text;
}
function fdParameters() {
return Type.Object({
pattern: Type.Optional(
Type.String({ description: FD_PARAMETER_DESCRIPTIONS.pattern }),
),
path: Type.Optional(
Type.String({ description: FD_PARAMETER_DESCRIPTIONS.path }),
),
type: Type.Optional(
StringEnum(["file", "directory", "symlink"] as const, {
description: FD_PARAMETER_DESCRIPTIONS.type,
}),
),
extension: Type.Optional(
Type.String({ description: FD_PARAMETER_DESCRIPTIONS.extension }),
),
glob: Type.Optional(
Type.Boolean({ description: FD_PARAMETER_DESCRIPTIONS.glob }),
),
hidden: Type.Optional(
Type.Boolean({ description: FD_PARAMETER_DESCRIPTIONS.hidden }),
),
max_depth: Type.Optional(
Type.Integer({
description: FD_PARAMETER_DESCRIPTIONS.max_depth,
minimum: 1,
maximum: FD_MAX_DEPTH_LIMIT,
}),
),
limit: Type.Optional(
Type.Integer({
description: FD_PARAMETER_DESCRIPTIONS.limit,
minimum: 1,
maximum: FD_MAX_LIMIT,
}),
),
});
}
function rgParameters() {
return Type.Object({
pattern: Type.String({ description: RG_PARAMETER_DESCRIPTIONS.pattern }),
path: Type.Optional(
Type.String({ description: RG_PARAMETER_DESCRIPTIONS.path }),
),
glob: Type.Optional(
Type.String({ description: RG_PARAMETER_DESCRIPTIONS.glob }),
),
file_type: Type.Optional(
Type.String({ description: RG_PARAMETER_DESCRIPTIONS.file_type }),
),
case_sensitive: Type.Optional(
Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.case_sensitive }),
),
fixed_strings: Type.Optional(
Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.fixed_strings }),
),
hidden: Type.Optional(
Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.hidden }),
),
context: Type.Optional(
Type.Integer({
description: RG_PARAMETER_DESCRIPTIONS.context,
minimum: 0,
maximum: RG_MAX_CONTEXT,
}),
),
limit: Type.Optional(
Type.Integer({
description: RG_PARAMETER_DESCRIPTIONS.limit,
minimum: 1,
maximum: RG_MAX_COUNT_LIMIT,
}),
),
});
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Pure CLI argument construction for the fd and rg tools.
*
* Everything here is synchronous and side-effect free so the exact argv
* passed to pi.exec can be asserted in tests. Patterns are always placed
* after a `--` separator so user-controlled input can never be parsed as a
* flag, and paths are normalized (leading `@`, `~` expansion) before use.
*/
import { homedir } from "node:os";
import { join } from "node:path";
export const FD_DEFAULT_LIMIT = 1000;
export const FD_MAX_LIMIT = 10_000;
export const FD_MAX_DEPTH_LIMIT = 64;
export const RG_DEFAULT_COUNT_LIMIT = 100;
export const RG_MAX_COUNT_LIMIT = 1000;
export const RG_MAX_CONTEXT = 20;
/** Some models prefix path arguments with @; built-in tools strip it, so do we. */
export function normalizeSearchPath(raw: string) {
let path = raw.trim();
if (path.startsWith("@")) path = path.slice(1);
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
return path;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, Math.floor(value)));
}
function optionalPath(raw: string | undefined) {
if (raw === undefined) return undefined;
const normalized = normalizeSearchPath(raw);
return normalized === "" ? undefined : normalized;
}
export type FdEntryType = "file" | "directory" | "symlink";
export interface FdToolParams {
pattern?: string;
path?: string;
type?: FdEntryType;
extension?: string;
glob?: boolean;
hidden?: boolean;
max_depth?: number;
limit?: number;
}
const FD_TYPE_FLAGS: Record<FdEntryType, string> = {
file: "f",
directory: "d",
symlink: "l",
};
export function buildFdArgs(params: FdToolParams) {
const args = ["--color=never"];
if (params.hidden) args.push("--hidden");
if (params.glob) args.push("--glob");
if (params.type) args.push("--type", FD_TYPE_FLAGS[params.type]);
if (params.extension) {
args.push("--extension", params.extension.replace(/^\.+/, ""));
}
if (params.max_depth !== undefined) {
args.push(
"--max-depth",
String(clamp(params.max_depth, 1, FD_MAX_DEPTH_LIMIT)),
);
}
args.push(
"--max-results",
String(clamp(params.limit ?? FD_DEFAULT_LIMIT, 1, FD_MAX_LIMIT)),
);
// An empty pattern matches everything, which keeps `path` usable without a pattern.
args.push("--", params.pattern ?? "");
const path = optionalPath(params.path);
if (path) args.push(path);
return args;
}
export interface RgToolParams {
pattern: string;
path?: string;
glob?: string;
file_type?: string;
case_sensitive?: boolean;
fixed_strings?: boolean;
hidden?: boolean;
context?: number;
limit?: number;
}
export function buildRgArgs(params: RgToolParams) {
const args = [
"--line-number",
"--color=never",
"--no-heading",
"--with-filename",
];
if (params.case_sensitive === true) args.push("--case-sensitive");
else if (params.case_sensitive === false) args.push("--ignore-case");
else args.push("--smart-case");
if (params.fixed_strings) args.push("--fixed-strings");
if (params.hidden) args.push("--hidden");
if (params.context !== undefined) {
args.push("--context", String(clamp(params.context, 0, RG_MAX_CONTEXT)));
}
if (params.glob) args.push("--glob", params.glob);
if (params.file_type) args.push("--type", params.file_type);
args.push(
"--max-count",
String(
clamp(params.limit ?? RG_DEFAULT_COUNT_LIMIT, 1, RG_MAX_COUNT_LIMIT),
),
);
args.push("--", params.pattern);
const path = optionalPath(params.path);
if (path) args.push(path);
return args;
}
+419
View File
@@ -0,0 +1,419 @@
/**
* Startup resolution of the fd and rg executables.
*
* Resolution order (per tool, first usable wins):
* 1. A normally installed system binary (`fd`/`fdfind`, `rg`) — used silently.
* 2. An existing fallback in this repository's `bin/` directory — used silently.
* 3. A fresh download of an official release into `bin/` — the only case that
* should surface a UI notification.
*
* The decision logic is an Effect over a small injectable environment
* (`BinaryEnv`) so tests can drive it without touching the filesystem or the
* network. `liveBinaryEnv` is the real implementation.
*/
import { NodeHttpClient, NodeServices } from "@effect/platform-node";
import { execFile } from "node:child_process";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Crypto, Data, Effect, Encoding, FileSystem, Stream } from "effect";
import { FetchHttpClient, HttpClient } from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
const execFileAsync = promisify(execFile);
export const FD_VERSION = "10.4.2";
export const FD_INTEL_DARWIN_VERSION = "10.3.0";
export const RG_VERSION = "15.2.0";
const DOWNLOAD_TIMEOUT_MS = 30_000;
const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
const MAX_DOWNLOAD_REDIRECTS = 10;
const FD_SHA256: Readonly<Record<string, string>> = {
"aarch64-apple-darwin":
"623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a",
"x86_64-apple-darwin":
"50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3",
"aarch64-unknown-linux-musl":
"f32d3657473fba74e2600babc8db0b93420d51169223b7e8143b2ed55d8fd9e8",
"x86_64-unknown-linux-musl":
"e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde",
};
const RG_SHA256: Readonly<Record<string, string>> = {
"aarch64-apple-darwin":
"3750b2e93f37e0c692657da574d7019a101c0084da05a790c83fd335bad973e4",
"x86_64-apple-darwin":
"af7825fcc69a2afc7a7aea55fc9af90e26421d8f20fe59df32e233c0b8a231c1",
"aarch64-unknown-linux-musl":
"800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915",
"x86_64-unknown-linux-musl":
"33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c",
};
export type ToolName = "fd" | "rg";
export type BinarySource = "system" | "bundled" | "installed";
export interface ToolSpec {
readonly tool: ToolName;
/** Commands probed on PATH, in order. Debian/Ubuntu install fd as `fdfind`. */
readonly systemCommands: readonly string[];
/** Executable name used inside release archives and the repo bin directory. */
readonly binaryName: string;
}
export const TOOL_SPECS: Record<ToolName, ToolSpec> = {
fd: { tool: "fd", systemCommands: ["fd", "fdfind"], binaryName: "fd" },
rg: { tool: "rg", systemCommands: ["rg"], binaryName: "rg" },
};
export interface PlatformTarget {
readonly os: string;
readonly arch: string;
}
export interface ReleaseAsset {
readonly url: string;
readonly fileName: string;
/** Top-level directory inside the tarball. */
readonly archiveDir: string;
readonly binaryName: string;
readonly version: string;
readonly sha256: string;
}
function targetTriple(target: PlatformTarget) {
const cpu =
target.arch === "arm64"
? "aarch64"
: target.arch === "x64"
? "x86_64"
: undefined;
if (!cpu) return undefined;
if (target.os === "darwin") return `${cpu}-apple-darwin`;
// musl builds are statically linked, so they run on any Linux distribution.
if (target.os === "linux") return `${cpu}-unknown-linux-musl`;
return undefined;
}
/** Official GitHub release asset for a tool on a platform, if supported. */
export function releaseAsset(
tool: ToolName,
target: PlatformTarget,
): ReleaseAsset | undefined {
const triple = targetTriple(target);
if (!triple) return undefined;
if (tool === "fd") {
const sha256 = FD_SHA256[triple];
if (!sha256) return undefined;
// fd 10.4.2 dropped the Intel macOS archive, so retain 10.3.0 there.
const version =
triple === "x86_64-apple-darwin" ? FD_INTEL_DARWIN_VERSION : FD_VERSION;
const archiveDir = `fd-v${version}-${triple}`;
const fileName = `${archiveDir}.tar.gz`;
return {
url: `https://github.com/sharkdp/fd/releases/download/v${version}/${fileName}`,
fileName,
archiveDir,
binaryName: "fd",
version,
sha256,
};
}
const sha256 = RG_SHA256[triple];
if (!sha256) return undefined;
const archiveDir = `ripgrep-${RG_VERSION}-${triple}`;
const fileName = `${archiveDir}.tar.gz`;
return {
url: `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${fileName}`,
fileName,
archiveDir,
binaryName: "rg",
version: RG_VERSION,
sha256,
};
}
export function currentTarget(): PlatformTarget {
return { os: process.platform, arch: process.arch };
}
/** Repository root (`~/.pi/agent`) resolved from this module's location. */
export function repositoryBinDir() {
const moduleDir = dirname(fileURLToPath(import.meta.url));
return join(moduleDir, "..", "..", "..", "bin");
}
export class UnsupportedPlatformError extends Data.TaggedError(
"UnsupportedPlatformError",
)<{
readonly message: string;
}> {}
export class InstallError extends Data.TaggedError("InstallError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
export interface BinaryEnv {
/** True when the executable runs and supports the flags this tool requires. */
readonly probe: (command: string, tool: ToolName) => Effect.Effect<boolean>;
/** Download and place a release binary at the destination path. */
readonly install: (
asset: ReleaseAsset,
destination: string,
) => Effect.Effect<void, InstallError>;
}
export interface ResolvedBinary {
readonly tool: ToolName;
/** Command or absolute path passed to pi.exec. */
readonly command: string;
readonly source: BinarySource;
readonly version?: string;
}
/** Resolve one tool: system binary, existing bin fallback, or fresh install. */
export function resolveBinary(
spec: ToolSpec,
binDir: string,
target: PlatformTarget,
env: BinaryEnv,
): Effect.Effect<ResolvedBinary, UnsupportedPlatformError | InstallError> {
return Effect.gen(function* () {
for (const command of spec.systemCommands) {
if (yield* env.probe(command, spec.tool)) {
return { tool: spec.tool, command, source: "system" as const };
}
}
const bundled = join(binDir, spec.binaryName);
if (yield* env.probe(bundled, spec.tool)) {
return { tool: spec.tool, command: bundled, source: "bundled" as const };
}
const asset = releaseAsset(spec.tool, target);
if (!asset) {
return yield* new UnsupportedPlatformError({
message: `No ${spec.tool} binary is available for ${target.os}/${target.arch}. Install ${spec.tool} manually and restart pi.`,
});
}
yield* env.install(asset, bundled);
if (!(yield* env.probe(bundled, spec.tool))) {
return yield* new InstallError({
message: `${spec.tool} ${asset.version} was installed to ${bundled} but failed to run.`,
});
}
return {
tool: spec.tool,
command: bundled,
source: "installed" as const,
version: asset.version,
};
});
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
/** Read a response incrementally while enforcing the startup memory bound. */
export function readBoundedResponse<E, R>(
response: {
readonly headers: Readonly<Record<string, string | undefined>>;
readonly stream: Stream.Stream<Uint8Array, E, R>;
},
maxBytes = MAX_ARCHIVE_BYTES,
) {
return Effect.gen(function* () {
const declaredLength = Number(response.headers["content-length"]);
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
return yield* Effect.fail(
new Error(`download exceeds the ${maxBytes}-byte size limit`),
);
}
const result = yield* Stream.runFoldEffect(
response.stream,
() => ({ chunks: [] as Uint8Array[], totalBytes: 0 }),
(accumulator, chunk) => {
const totalBytes = accumulator.totalBytes + chunk.byteLength;
if (totalBytes > maxBytes) {
return Effect.fail(
new Error(`download exceeds the ${maxBytes}-byte size limit`),
);
}
return Effect.sync(() => {
accumulator.chunks.push(chunk);
accumulator.totalBytes = totalBytes;
return accumulator;
});
},
);
return Buffer.concat(result.chunks, result.totalBytes);
});
}
function downloadAsset(client: HttpClient.HttpClient, initialUrl: URL) {
const scopedClient = client.pipe(HttpClient.withScope);
return Effect.gen(function* () {
let url = initialUrl;
for (let redirects = 0; redirects <= MAX_DOWNLOAD_REDIRECTS; redirects++) {
if (url.protocol !== "https:") {
return yield* Effect.fail(
new Error(`refusing non-HTTPS download URL: ${url.href}`),
);
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
const response = yield* scopedClient.get(url);
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.location;
if (!location) {
return yield* Effect.fail(
new Error(`redirect from ${url.href} had no location header`),
);
}
if (redirects === MAX_DOWNLOAD_REDIRECTS) {
return yield* Effect.fail(
new Error(
`download exceeded ${MAX_DOWNLOAD_REDIRECTS} redirects`,
),
);
}
if (!URL.canParse(location, url)) {
return yield* Effect.fail(
new Error(
`download returned an invalid redirect URL: ${location}`,
),
);
}
return { _tag: "Redirect" as const, url: new URL(location, url) };
}
if (response.status < 200 || response.status >= 300) {
return yield* Effect.fail(
new Error(`download failed with HTTP ${response.status}`),
);
}
return {
_tag: "Complete" as const,
bytes: yield* readBoundedResponse(response),
};
}),
);
if (result._tag === "Complete") return result.bytes;
url = result.url;
}
return yield* Effect.fail(new Error("download redirect handling failed"));
});
}
/** Real environment: probes via `--version`, installs via HTTPS + tar. */
export const liveBinaryEnv: BinaryEnv = {
probe: (command, tool) =>
Effect.promise(async () => {
try {
const args =
tool === "fd" ? ["--max-results", "1", "--", ""] : ["--version"];
await execFileAsync(command, args, {
cwd: tmpdir(),
timeout: 5_000,
});
return true;
} catch {
return false;
}
}),
install: (asset, destination) => {
const install = Effect.gen(function* () {
if (!URL.canParse(asset.url)) {
return yield* Effect.fail(
new Error(`invalid download URL: ${asset.url}`),
);
}
const url = new URL(asset.url);
const client = yield* HttpClient.HttpClient;
const fs = yield* FileSystem.FileSystem;
const crypto = yield* Crypto.Crypto;
const bytes = yield* downloadAsset(client, url).pipe(
Effect.timeout(DOWNLOAD_TIMEOUT_MS),
);
const digestBytes = yield* crypto.digest("SHA-256", bytes);
const digest = Encoding.encodeHex(digestBytes);
if (digest !== asset.sha256) {
return yield* Effect.fail(
new Error(
`SHA-256 mismatch for ${asset.fileName}: expected ${asset.sha256}, received ${digest}`,
),
);
}
const workDir = yield* fs.makeTempDirectoryScoped({
prefix: "pi-file-search-",
});
const archivePath = join(workDir, asset.fileName);
yield* fs.writeFile(archivePath, bytes);
const tarExitCode = yield* Effect.scoped(
Effect.gen(function* () {
const tar = yield* ChildProcess.make(
"tar",
["-xzf", archivePath, "-C", workDir],
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
);
return yield* tar.exitCode;
}),
).pipe(Effect.timeout(60_000));
if (tarExitCode !== ChildProcessSpawner.ExitCode(0)) {
return yield* Effect.fail(
new Error(`tar failed with exit code ${tarExitCode}`),
);
}
const extracted = join(workDir, asset.archiveDir, asset.binaryName);
yield* fs.makeDirectory(dirname(destination), { recursive: true });
const uuid = yield* crypto.randomUUIDv4;
const stagedDestination = `${destination}.${process.pid}.${uuid}.tmp`;
yield* Effect.addFinalizer(() =>
fs.remove(stagedDestination, { force: true }).pipe(Effect.orDie),
);
yield* fs.copyFile(extracted, stagedDestination);
yield* fs.chmod(stagedDestination, 0o755);
yield* fs.rename(stagedDestination, destination);
});
return install.pipe(
Effect.scoped,
Effect.provide(NodeServices.layer),
Effect.provide(NodeHttpClient.layerFetch),
Effect.provideService(FetchHttpClient.RequestInit, {
redirect: "manual",
}),
Effect.mapError(
(cause) =>
new InstallError({
message: `Failed to install ${asset.binaryName} ${asset.version} from ${asset.url}: ${errorMessage(cause)}`,
cause,
}),
),
);
},
};
+126
View File
@@ -0,0 +1,126 @@
/**
* Shared output shaping for the fd and rg tools: standard pi truncation
* (2000 lines / 50KB) with the full output persisted to a temp file when
* anything is cut off.
*/
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
truncateHead,
} from "@earendil-works/pi-coding-agent";
export interface FormattedOutput {
readonly text: string;
readonly lineCount: number;
readonly truncated: boolean;
readonly fullOutputPath?: string;
}
export interface CapturedOutput {
readonly preview: string;
readonly lineCount: number;
readonly totalBytes: number;
readonly truncated: boolean;
readonly fullOutputPath?: string;
}
export interface FormatOutputOptions {
/** Temp-file prefix, e.g. "pi-fd-". */
readonly tempPrefix: string;
/** Injectable for tests. */
readonly persistFullOutput?: (output: string) => Promise<string>;
}
async function persistToTempFile(prefix: string, output: string) {
const directory = await mkdtemp(join(tmpdir(), prefix));
const path = join(directory, "output.txt");
await writeFile(path, output, "utf8");
return path;
}
function truncationNotice(options: {
content: string;
outputLines: number;
totalLines: number;
outputBytes: number;
totalBytes: number;
fullOutputPath: string;
}) {
return (
`${options.content}\n\n[Output truncated: ${options.outputLines} of ${options.totalLines} lines ` +
`(${formatSize(options.outputBytes)} of ${formatSize(options.totalBytes)}). ` +
`Full output saved to: ${options.fullOutputPath}]`
);
}
/** Format output already captured by a bounded-memory streaming process. */
export function formatCapturedOutput(captured: CapturedOutput) {
const trimmed = captured.preview.replace(/\n+$/, "");
if (!captured.truncated || !captured.fullOutputPath) {
return {
text: trimmed,
lineCount: captured.lineCount,
truncated: false,
} satisfies FormattedOutput;
}
const truncation = truncateHead(trimmed, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
const content = truncation.content;
const outputLines = content === "" ? 0 : content.split("\n").length;
const outputBytes = Buffer.byteLength(content);
return {
text: truncationNotice({
content,
outputLines,
totalLines: captured.lineCount,
outputBytes,
totalBytes: captured.totalBytes,
fullOutputPath: captured.fullOutputPath,
}),
lineCount: captured.lineCount,
truncated: true,
fullOutputPath: captured.fullOutputPath,
} satisfies FormattedOutput;
}
/** Truncate to pi's standard limits, persisting the full output when cut. */
export async function formatOutput(
output: string,
options: FormatOutputOptions,
): Promise<FormattedOutput> {
const trimmed = output.replace(/\n+$/, "");
const lineCount = trimmed === "" ? 0 : trimmed.split("\n").length;
const truncation = truncateHead(trimmed, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
if (!truncation.truncated) {
return { text: trimmed, lineCount, truncated: false };
}
const persist =
options.persistFullOutput ??
((full: string) => persistToTempFile(options.tempPrefix, full));
const fullOutputPath = await persist(trimmed);
const text = truncationNotice({
content: truncation.content,
outputLines: truncation.outputLines,
totalLines: truncation.totalLines,
outputBytes: truncation.outputBytes,
totalBytes: truncation.totalBytes,
fullOutputPath,
});
return { text, lineCount, truncated: true, fullOutputPath };
}
+146
View File
@@ -0,0 +1,146 @@
import { dirname, join } from "node:path";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
truncateHead,
} from "@earendil-works/pi-coding-agent";
import { Effect, FileSystem, Stream } from "effect";
import { ChildProcess } from "effect/unstable/process";
import type { CapturedOutput } from "./output.ts";
const STDERR_MAX_BYTES = 64 * 1024;
interface PreviewState {
readonly decoder: TextDecoder;
preview: string;
totalBytes: number;
lineBreaks: number;
trailingLineBreaks: number;
truncated: boolean;
}
function makePreviewState(): PreviewState {
return {
decoder: new TextDecoder(),
preview: "",
totalBytes: 0,
lineBreaks: 0,
trailingLineBreaks: 0,
truncated: false,
};
}
function observeStdout(state: PreviewState, chunk: Uint8Array) {
state.totalBytes += chunk.byteLength;
for (const byte of chunk) {
if (byte === 0x0a) {
state.lineBreaks++;
state.trailingLineBreaks++;
} else {
state.trailingLineBreaks = 0;
}
}
if (state.truncated) return;
state.preview += state.decoder.decode(chunk, { stream: true });
const truncation = truncateHead(state.preview, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
if (truncation.truncated) {
state.preview = truncation.content;
state.truncated = true;
}
}
function finishStdout(state: PreviewState, fullOutputPath: string) {
if (!state.truncated) state.preview += state.decoder.decode();
const totalBytes = state.totalBytes - state.trailingLineBreaks;
const lineCount =
totalBytes === 0 ? 0 : state.lineBreaks - state.trailingLineBreaks + 1;
return {
preview: state.preview,
lineCount,
totalBytes,
truncated: state.truncated,
fullOutputPath: state.truncated ? fullOutputPath : undefined,
} satisfies CapturedOutput;
}
function collectStderr<E, R>(stream: Stream.Stream<Uint8Array, E, R>) {
return Stream.runFold(
stream,
() => Buffer.alloc(0),
(captured, chunk) => {
if (captured.byteLength >= STDERR_MAX_BYTES) return captured;
const remaining = STDERR_MAX_BYTES - captured.byteLength;
return Buffer.concat([captured, chunk.subarray(0, remaining)]);
},
).pipe(Effect.map((bytes) => bytes.toString("utf8")));
}
export function executeSearchProcess(options: {
readonly command: string;
readonly args: readonly string[];
readonly cwd: string;
readonly tempPrefix: string;
}) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const directory = yield* fs.makeTempDirectory({
prefix: options.tempPrefix,
});
const fullOutputPath = join(directory, "output.txt");
let retainDirectory = false;
return yield* Effect.gen(function* () {
const preview = makePreviewState();
const process = yield* ChildProcess.make(options.command, options.args, {
cwd: options.cwd,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const result = yield* Effect.all(
{
exitCode: process.exitCode,
stdout: process.stdout.pipe(
Stream.tap((chunk) =>
Effect.sync(() => observeStdout(preview, chunk)),
),
Stream.run(fs.sink(fullOutputPath)),
),
stderr: collectStderr(process.stderr),
},
{ concurrency: "unbounded" },
);
const output = finishStdout(preview, fullOutputPath);
retainDirectory = output.truncated;
return {
code: Number(result.exitCode),
stderr: result.stderr,
output,
};
}).pipe(
Effect.ensuring(
Effect.suspend(() =>
retainDirectory
? Effect.void
: fs
.remove(directory, { recursive: true, force: true })
.pipe(Effect.orDie),
),
),
);
}).pipe(Effect.scoped);
}
export function discardCapturedOutput(output: CapturedOutput) {
if (!output.fullOutputPath) return Effect.void;
const directory = dirname(output.fullOutputPath);
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
yield* fs.remove(directory, { recursive: true, force: true });
});
}
+52
View File
@@ -0,0 +1,52 @@
/** Model-facing text for the fd and rg tools. */
export const FD_TOOL_DESCRIPTION =
"Find files and directories by name with fd. Respects .gitignore by default. Results are limited to 1000 entries unless a higher limit is given; output is limited to 2000 lines or 50KB, and complete truncated output is saved to a temporary file.";
export const FD_PROMPT_SNIPPET =
"Find files and directories by name with fd (fast, gitignore-aware).";
export const FD_PROMPT_GUIDELINES = [
"Use fd as the primary tool for discovering files and directories by name, extension, or glob instead of bash with find or ls -R.",
"Use rg instead of fd when searching file contents rather than file names.",
"Keep using bash for complex multi-step workflows that pipe or post-process file listings.",
];
export const FD_PARAMETER_DESCRIPTIONS = {
pattern:
"Regex matched against file names (or a glob when glob is true). Omit to list everything under path.",
path: "Directory to search. Defaults to the current working directory.",
type: "Only return entries of this type: file, directory, or symlink.",
extension: "Only return files with this extension, e.g. 'ts' or 'md'.",
glob: "Treat pattern as a glob (e.g. '*.test.ts') instead of a regex.",
hidden: "Include hidden files and directories. Defaults to false.",
max_depth: "Maximum directory depth to descend (1-64).",
limit: "Maximum number of results (1-10000). Defaults to 1000.",
};
export const RG_TOOL_DESCRIPTION =
"Search file contents with ripgrep. Uses smart-case matching, respects .gitignore by default, and returns at most 100 matches per file unless a different limit is given. Output is limited to 2000 lines or 50KB; complete truncated output is saved to a temporary file.";
export const RG_PROMPT_SNIPPET =
"Search file contents with ripgrep (fast regex content search).";
export const RG_PROMPT_GUIDELINES = [
"Use rg as the primary tool for searching file contents instead of bash with grep.",
"Use fd instead of rg when looking for files by name rather than content.",
"Set fixed_strings on rg when searching for literal code snippets containing regex metacharacters.",
"Keep using bash for complex multi-step workflows that combine searching with other commands.",
];
export const RG_PARAMETER_DESCRIPTIONS = {
pattern: "Regex to search for (literal text when fixed_strings is true).",
path: "File or directory to search. Defaults to the current working directory.",
glob: "Only search files matching this glob, e.g. '*.ts' or 'src/**'.",
file_type:
"Only search files of this ripgrep type, e.g. 'ts', 'js', 'py', 'rust'.",
case_sensitive:
"true forces case-sensitive matching, false forces case-insensitive. Defaults to smart-case.",
fixed_strings: "Treat pattern as a literal string instead of a regex.",
hidden: "Search hidden files and directories. Defaults to false.",
context: "Lines of context to show around each match (0-20).",
limit: "Maximum matches per file (1-1000). Defaults to 100.",
};
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Effect } from "effect";
import { crawlEffect, type CrawlClient } from "./index.ts";
test("cancels the remote crawl when polling is interrupted", async () => {
let pollingStarted!: () => void;
const startedPolling = new Promise<void>((resolve) => {
pollingStarted = resolve;
});
const cancelledJobs: string[] = [];
const client: CrawlClient = {
startCrawl: async (url) => ({ id: "crawl-123", url }),
getCrawlStatus: async () => {
pollingStarted();
return new Promise(() => undefined);
},
cancelCrawl: async (jobId) => {
cancelledJobs.push(jobId);
return true;
},
};
const controller = new AbortController();
const running = Effect.runPromise(
crawlEffect(client, "https://example.com", { limit: 1 }),
{ signal: controller.signal },
);
const interrupted = assert.rejects(running);
await startedPolling;
controller.abort();
await interrupted;
assert.deepEqual(cancelledJobs, ["crawl-123"]);
});
+433
View File
@@ -0,0 +1,433 @@
import { readFileSync } from "node:fs";
import { mkdtemp, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { StringEnum } from "@earendil-works/pi-ai";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
truncateHead,
type AgentToolResult,
type AgentToolUpdateCallback,
type ExtensionAPI,
} from "@earendil-works/pi-coding-agent";
import { Cause, Data, Effect, Exit } from "effect";
import { Firecrawl, type CrawlJob, type CrawlOptions } from "firecrawl";
import { Type } from "typebox";
import {
CRAWL_PARAMETER_DESCRIPTIONS,
CRAWL_PROMPT_GUIDELINES,
CRAWL_PROMPT_SNIPPET,
CRAWL_TOOL_DESCRIPTION,
SCRAPE_PARAMETER_DESCRIPTIONS,
SCRAPE_PROMPT_GUIDELINES,
SCRAPE_PROMPT_SNIPPET,
SCRAPE_TOOL_DESCRIPTION,
SEARCH_PARAMETER_DESCRIPTIONS,
SEARCH_PROMPT_GUIDELINES,
SEARCH_PROMPT_SNIPPET,
SEARCH_TOOL_DESCRIPTION,
} from "./prompt.ts";
function readEnvValue(name: string) {
if (process.env[name]) return process.env[name];
const envPath = join(homedir(), ".pi", "agent", ".env");
let envText = "";
try {
envText = readFileSync(envPath, "utf8");
} catch {
return undefined;
}
for (const line of envText.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const match = trimmed.match(
/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/,
);
if (!match || match[1] !== name) continue;
const value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value.replace(/\s+#.*$/, "");
}
return undefined;
}
class MissingApiKeyError extends Data.TaggedError("MissingApiKeyError")<{
readonly message: string;
}> {}
function createClient() {
const apiKey = readEnvValue("FIRECRAWL_API_KEY");
if (!apiKey) {
return Effect.fail(
new MissingApiKeyError({
message:
"Missing FIRECRAWL_API_KEY in the environment or ~/.pi/agent/.env",
}),
);
}
return Effect.try({
try: () => new Firecrawl({ apiKey }),
catch: (cause) =>
new FirecrawlError({ message: errorMessage(cause), cause }),
});
}
function stringify(value: unknown) {
return JSON.stringify(value, null, 2);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
class FirecrawlError extends Data.TaggedError("FirecrawlError")<{
readonly message: string;
readonly cause: unknown;
}> {}
function firecrawlRequest<T>(request: () => Promise<T>) {
return Effect.tryPromise({
try: request,
catch: (cause) =>
new FirecrawlError({ message: errorMessage(cause), cause }),
});
}
class OutputError extends Data.TaggedError("OutputError")<{
readonly message: string;
readonly cause: unknown;
}> {}
function formatOutput(value: unknown, operation: string) {
return Effect.tryPromise({
try: async () => {
const output = typeof value === "string" ? value : stringify(value);
const truncation = truncateHead(output, {
maxBytes: DEFAULT_MAX_BYTES,
maxLines: DEFAULT_MAX_LINES,
});
if (!truncation.truncated) return output;
const outputDirectory = await mkdtemp(join(tmpdir(), "pi-firecrawl-"));
const outputPath = join(outputDirectory, `${operation}.json`);
await writeFile(outputPath, output, "utf8");
return `${truncation.content}\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${outputPath}]`;
},
catch: (cause) => new OutputError({ message: errorMessage(cause), cause }),
});
}
export type CrawlClient = Pick<
Firecrawl,
"startCrawl" | "getCrawlStatus" | "cancelCrawl"
>;
function pollCrawl(
client: CrawlClient,
jobId: string,
): Effect.Effect<CrawlJob, FirecrawlError> {
return firecrawlRequest(() => client.getCrawlStatus(jobId)).pipe(
Effect.flatMap((job) =>
job.status === "scraping"
? Effect.sleep("2 seconds").pipe(
Effect.flatMap(() =>
Effect.suspend(() => pollCrawl(client, jobId)),
),
)
: Effect.succeed(job),
),
);
}
/** Brackets the remote job so every non-successful exit attempts cancellation. */
export function crawlEffect(
client: CrawlClient,
url: string,
options: CrawlOptions,
) {
return Effect.acquireUseRelease(
firecrawlRequest(() => client.startCrawl(url, options)),
(job) => pollCrawl(client, job.id),
(job, exit) =>
Exit.isSuccess(exit)
? Effect.void
: firecrawlRequest(() => client.cancelCrawl(job.id)).pipe(
Effect.timeout("10 seconds"),
Effect.ignore,
),
);
}
function operationError(operation: string, error: unknown) {
if (error instanceof MissingApiKeyError) return new Error(error.message);
const cause =
error instanceof FirecrawlError || error instanceof OutputError
? error.cause
: error;
return new Error(`Firecrawl ${operation} failed: ${errorMessage(error)}`, {
cause,
});
}
/** Shared Effect pipeline with a single Promise boundary for the tool API. */
async function runFirecrawl<T>(
operation: string,
status: string,
timeout: number,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<T | undefined> | undefined,
request: (
client: Firecrawl,
) => Effect.Effect<
{ details: T; output: unknown },
FirecrawlError | OutputError
>,
) {
const program = Effect.gen(function* () {
const client = yield* createClient();
yield* Effect.sync(() =>
onUpdate?.({
content: [{ type: "text", text: status }],
details: undefined,
}),
);
const { details, output } = yield* request(client).pipe(
Effect.timeout(timeout),
);
const formatted = yield* formatOutput(output, operation);
return {
content: [{ type: "text" as const, text: formatted }],
details,
} satisfies AgentToolResult<T | undefined>;
});
const exit = await Effect.runPromiseExit(
program,
signal ? { signal } : undefined,
);
if (Exit.isSuccess(exit)) return exit.value;
if (Cause.hasInterruptsOnly(exit.cause)) {
throw new Error("Firecrawl request cancelled");
}
throw operationError(operation, Cause.squash(exit.cause));
}
export default function firecrawlTools(pi: ExtensionAPI) {
pi.registerTool({
name: "search",
label: "Search Web",
description: SEARCH_TOOL_DESCRIPTION,
promptSnippet: SEARCH_PROMPT_SNIPPET,
promptGuidelines: SEARCH_PROMPT_GUIDELINES,
parameters: Type.Object({
query: Type.String({
description: SEARCH_PARAMETER_DESCRIPTIONS.query,
}),
limit: Type.Optional(
Type.Number({
description: SEARCH_PARAMETER_DESCRIPTIONS.limit,
minimum: 1,
maximum: 20,
}),
),
source: Type.Optional(StringEnum(["web", "news", "images"] as const)),
scrapeResults: Type.Optional(
Type.Boolean({
description: SEARCH_PARAMETER_DESCRIPTIONS.scrapeResults,
}),
),
}),
execute: (_toolCallId, params, signal, onUpdate) =>
runFirecrawl(
"search",
`Searching Firecrawl for: ${params.query}`,
35_000,
signal,
onUpdate,
(client) =>
firecrawlRequest(() =>
client.search(params.query, {
limit: params.limit ?? 5,
sources: [params.source ?? "web"],
scrapeOptions: params.scrapeResults
? { formats: ["markdown"], timeout: 30_000 }
: undefined,
timeout: 30_000,
}),
).pipe(Effect.map((result) => ({ details: result, output: result }))),
),
});
pi.registerTool({
name: "crawl",
label: "Crawl Website",
description: CRAWL_TOOL_DESCRIPTION,
promptSnippet: CRAWL_PROMPT_SNIPPET,
promptGuidelines: CRAWL_PROMPT_GUIDELINES,
parameters: Type.Object({
url: Type.String({ description: CRAWL_PARAMETER_DESCRIPTIONS.url }),
limit: Type.Optional(
Type.Number({
description: CRAWL_PARAMETER_DESCRIPTIONS.limit,
minimum: 1,
maximum: 100,
}),
),
maxDiscoveryDepth: Type.Optional(
Type.Number({
description: CRAWL_PARAMETER_DESCRIPTIONS.maxDiscoveryDepth,
minimum: 0,
}),
),
includePaths: Type.Optional(
Type.Array(Type.String(), {
description: CRAWL_PARAMETER_DESCRIPTIONS.includePaths,
}),
),
excludePaths: Type.Optional(
Type.Array(Type.String(), {
description: CRAWL_PARAMETER_DESCRIPTIONS.excludePaths,
}),
),
crawlEntireDomain: Type.Optional(
Type.Boolean({
description: CRAWL_PARAMETER_DESCRIPTIONS.crawlEntireDomain,
}),
),
allowSubdomains: Type.Optional(
Type.Boolean({
description: CRAWL_PARAMETER_DESCRIPTIONS.allowSubdomains,
}),
),
sitemap: Type.Optional(StringEnum(["include", "skip", "only"] as const)),
onlyMainContent: Type.Optional(
Type.Boolean({
description: CRAWL_PARAMETER_DESCRIPTIONS.onlyMainContent,
}),
),
timeout: Type.Optional(
Type.Number({
description: CRAWL_PARAMETER_DESCRIPTIONS.timeout,
minimum: 1,
maximum: 600,
}),
),
}),
execute: (_toolCallId, params, signal, onUpdate) =>
runFirecrawl(
"crawl",
`Crawling up to ${params.limit ?? 20} pages from: ${params.url}`,
((params.timeout ?? 120) + 5) * 1_000,
signal,
onUpdate,
(client) =>
crawlEffect(client, params.url, {
limit: params.limit ?? 20,
maxDiscoveryDepth: params.maxDiscoveryDepth,
includePaths: params.includePaths,
excludePaths: params.excludePaths,
crawlEntireDomain: params.crawlEntireDomain,
allowSubdomains: params.allowSubdomains,
sitemap: params.sitemap,
scrapeOptions: {
formats: ["markdown"],
onlyMainContent: params.onlyMainContent ?? true,
},
}).pipe(
Effect.map((result) => ({ details: result, output: result })),
),
),
});
pi.registerTool({
name: "scrape",
label: "Scrape Page",
description: SCRAPE_TOOL_DESCRIPTION,
promptSnippet: SCRAPE_PROMPT_SNIPPET,
promptGuidelines: SCRAPE_PROMPT_GUIDELINES,
parameters: Type.Object({
url: Type.String({ description: SCRAPE_PARAMETER_DESCRIPTIONS.url }),
onlyMainContent: Type.Optional(
Type.Boolean({
description: SCRAPE_PARAMETER_DESCRIPTIONS.onlyMainContent,
}),
),
waitFor: Type.Optional(
Type.Number({
description: SCRAPE_PARAMETER_DESCRIPTIONS.waitFor,
minimum: 0,
maximum: 60_000,
}),
),
timeout: Type.Optional(
Type.Number({
description: SCRAPE_PARAMETER_DESCRIPTIONS.timeout,
minimum: 1,
maximum: 120_000,
}),
),
includeMetadata: Type.Optional(
Type.Boolean({
description: SCRAPE_PARAMETER_DESCRIPTIONS.includeMetadata,
}),
),
}),
execute: (_toolCallId, params, signal, onUpdate) =>
runFirecrawl(
"scrape",
`Scraping page with Firecrawl: ${params.url}`,
(params.timeout ?? 30_000) + 5_000,
signal,
onUpdate,
(client) =>
firecrawlRequest(() =>
client.scrape(params.url, {
formats: ["markdown"],
onlyMainContent: params.onlyMainContent ?? true,
waitFor: params.waitFor,
timeout: params.timeout ?? 30_000,
}),
).pipe(
Effect.flatMap((document) =>
Effect.try({
try: () => {
const metadata =
params.includeMetadata && document.metadata
? `\n\nMetadata:\n${stringify(document.metadata)}`
: "";
const markdown =
document.markdown?.trim() ||
"No markdown content returned.";
return {
details: document,
output: `${markdown}${metadata}`,
};
},
catch: (cause) =>
new OutputError({ message: errorMessage(cause), cause }),
}),
),
),
),
});
}
+76
View File
@@ -0,0 +1,76 @@
/** Describes Firecrawl search and its model-context output limits. */
export const SEARCH_TOOL_DESCRIPTION =
"Search the web with Firecrawl. Returns web, news, or image results. Output is limited to 50KB or 2000 lines; complete truncated output is saved to a temporary file.";
/** Adds Firecrawl's current-information search capability to the model's tool prompt. */
export const SEARCH_PROMPT_SNIPPET =
"Search the web with Firecrawl for current information.";
/** Guides the model on when to search and when to follow with scrape or crawl. */
export const SEARCH_PROMPT_GUIDELINES = [
"Use search when the user asks for current web information, discovery, or sources beyond the local workspace.",
"Use scrape after search when you need the full readable content of a specific page.",
"Use crawl when the user needs content from multiple pages of the same website.",
];
/** Model-facing schema descriptions for Firecrawl search parameters. */
export const SEARCH_PARAMETER_DESCRIPTIONS = {
query: "The web search query.",
limit: "Maximum number of results. Defaults to 5.",
scrapeResults:
"Whether to include markdown scraped from each result. Defaults to false.",
};
/** Describes multi-page Firecrawl crawling and its page and output limits. */
export const CRAWL_TOOL_DESCRIPTION =
"Crawl multiple pages of a website with Firecrawl and return markdown documents. Defaults to 20 pages and never accepts a limit above 100. Output is limited to 50KB or 2000 lines; complete truncated output is saved to a temporary file.";
/** Adds Firecrawl's multi-page crawl capability to the model's tool prompt. */
export const CRAWL_PROMPT_SNIPPET =
"Crawl multiple pages of a website with Firecrawl.";
/** Guides the model to use focused crawl limits and prefer scrape for one URL. */
export const CRAWL_PROMPT_GUIDELINES = [
"Use crawl when the user needs content from multiple related pages on one website.",
"Keep crawl limits as low as practical because each crawled page consumes Firecrawl credits.",
"Use scrape instead of crawl when only one known URL is needed.",
];
/** Model-facing schema descriptions for Firecrawl crawl parameters. */
export const CRAWL_PARAMETER_DESCRIPTIONS = {
url: "The starting URL to crawl.",
limit: "Maximum pages to crawl. Defaults to 20; maximum 100.",
maxDiscoveryDepth: "Maximum link-discovery depth from the starting URL.",
includePaths: "URL pathname regex patterns to include.",
excludePaths: "URL pathname regex patterns to exclude.",
crawlEntireDomain: "Allow sibling and parent paths on the same domain.",
allowSubdomains: "Allow crawling subdomains.",
onlyMainContent: "Extract only each page's main content. Defaults to true.",
timeout: "Maximum crawl wait time in seconds. Defaults to 120.",
};
/** Describes single-page Firecrawl scraping and its model-context output limits. */
export const SCRAPE_TOOL_DESCRIPTION =
"Scrape one page with Firecrawl and return markdown. Output is limited to 50KB or 2000 lines; complete truncated output is saved to a temporary file.";
/** Adds Firecrawl's readable single-page fetch capability to the model's tool prompt. */
export const SCRAPE_PROMPT_SNIPPET =
"Fetch one URL as readable markdown with Firecrawl.";
/** Guides the model to use scrape for one known page and crawl for multiple pages. */
export const SCRAPE_PROMPT_GUIDELINES = [
"Use scrape when you need the full readable markdown content of one known URL.",
"Prefer scrape over bash or raw HTTP fetching for web pages because scrape returns cleaned content.",
"Use crawl instead when content is needed from multiple pages on the same website.",
];
/** Model-facing schema descriptions for Firecrawl scrape parameters. */
export const SCRAPE_PARAMETER_DESCRIPTIONS = {
url: "The URL to scrape.",
onlyMainContent: "Return only the main page content. Defaults to true.",
waitFor:
"Milliseconds to wait before capture, useful for JavaScript-heavy pages.",
timeout: "Request timeout in milliseconds. Defaults to 30000.",
includeMetadata:
"Append page metadata to the markdown. Defaults to false; metadata remains available in tool details.",
};
+31
View File
@@ -0,0 +1,31 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
type Theme = ExtensionContext["ui"]["theme"];
interface ActivityCounts {
running: number;
done: number;
failed: number;
}
const SQUARE = "■";
export function formatActivityStatus(
theme: Theme,
label: "subagents" | "workflows",
counts: ActivityCounts,
) {
const parts: string[] = [];
if (counts.running > 0) {
parts.push(theme.fg("warning", `${SQUARE} ${counts.running} running`));
}
if (counts.done > 0) {
parts.push(theme.fg("success", `${SQUARE} ${counts.done} done`));
}
if (counts.failed > 0) {
parts.push(theme.fg("error", `${SQUARE} ${counts.failed} failed`));
}
parts.push(theme.fg("accent", `/${label}`) + theme.fg("dim", " to view"));
return `${theme.fg("muted", `${label}:`)} ${parts.join(theme.fg("dim", " · "))}`;
}
+270
View File
@@ -0,0 +1,270 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import {
createAgentSession,
DefaultResourceLoader,
defineTool,
ProjectTrustStore,
SessionManager,
SettingsManager,
type SessionShutdownEvent,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
bindChildSessionExtensions,
CHILD_EXCLUDED_TOOL_NAMES,
childToolPolicy,
createChildResources,
resolveStandaloneChildProjectTrust,
shutdownAndDisposeChildSession,
type DisposableChildSession,
} from "./child-session.ts";
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "pi-child-policy-"));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
test("child denylist keeps extension and workflow structured tools available", async () => {
await withTempDir(async (directory) => {
let starts = 0;
let shutdowns = 0;
const settingsManager = SettingsManager.inMemory(undefined, {
projectTrusted: false,
});
const inlineLoader = new DefaultResourceLoader({
cwd: directory,
agentDir: path.join(directory, "inline-agent"),
settingsManager,
extensionFactories: [
(pi) => {
pi.on("session_start", () => {
starts++;
});
pi.on("session_shutdown", () => {
shutdowns++;
});
for (const name of [
"fixture_extension_tool",
...CHILD_EXCLUDED_TOOL_NAMES,
]) {
pi.registerTool({
name,
label: name,
description: name,
parameters: Type.Object({}),
async execute() {
return {
content: [{ type: "text", text: "ok" }],
details: {},
};
},
});
}
},
],
});
await inlineLoader.reload();
const structuredOutput = defineTool({
name: "structured_output",
label: "Structured Output",
description: "fixture structured result",
parameters: Type.Object({ value: Type.String() }),
async execute(_id, params) {
return {
content: [{ type: "text", text: params.value }],
details: {},
};
},
});
const { session } = await createAgentSession({
cwd: directory,
agentDir: path.join(directory, "inline-agent"),
resourceLoader: inlineLoader,
settingsManager,
sessionManager: SessionManager.inMemory(directory),
customTools: [structuredOutput],
...childToolPolicy(),
});
await bindChildSessionExtensions(session);
assert.deepEqual(
[...CHILD_EXCLUDED_TOOL_NAMES],
[
"subagent_spawn",
"subagent_wait",
"subagent_cancel",
"subagent_check",
"subagent_list",
"workflow",
"ask_user",
],
);
const allTools = new Set(session.getAllTools().map((tool) => tool.name));
const activeTools = new Set(session.getActiveToolNames());
assert.equal(starts, 1);
assert.equal(allTools.has("fixture_extension_tool"), true);
assert.equal(activeTools.has("fixture_extension_tool"), true);
assert.equal(allTools.has("structured_output"), true);
assert.equal(activeTools.has("structured_output"), true);
for (const denied of CHILD_EXCLUDED_TOOL_NAMES) {
assert.equal(allTools.has(denied), false, `${denied} should be denied`);
assert.equal(
activeTools.has(denied),
false,
`${denied} should be inactive`,
);
}
for (const builtin of ["read", "bash", "edit", "write"]) {
assert.equal(
activeTools.has(builtin),
true,
`${builtin} should stay active`,
);
}
await Promise.all([
shutdownAndDisposeChildSession(session),
shutdownAndDisposeChildSession(session),
]);
assert.equal(shutdowns, 1);
});
});
test("resource loading gates project extensions but retains global extensions", async () => {
await withTempDir(async (directory) => {
const cwd = path.join(directory, "project");
const agentDir = path.join(directory, "agent");
await mkdir(path.join(cwd, ".pi", "extensions"), { recursive: true });
await mkdir(path.join(agentDir, "extensions"), { recursive: true });
const extensionSource = (name: string) => `
export default function (pi) {
pi.registerTool({
name: ${JSON.stringify(name)}, label: ${JSON.stringify(name)},
description: "fixture", parameters: { type: "object", properties: {} },
async execute() { return { content: [{ type: "text", text: "ok" }] }; }
});
}
`;
await writeFile(
path.join(agentDir, "extensions", "global.ts"),
extensionSource("global_fixture"),
);
await writeFile(
path.join(cwd, ".pi", "extensions", "project.ts"),
extensionSource("project_fixture"),
);
const untrusted = await createChildResources({
cwd,
agentDir,
projectTrusted: false,
});
const untrustedTools = untrusted.loader
.getExtensions()
.extensions.flatMap((extension) => [...extension.tools.keys()]);
assert.equal(untrustedTools.includes("global_fixture"), true);
assert.equal(untrustedTools.includes("project_fixture"), false);
const trusted = await createChildResources({
cwd,
agentDir,
projectTrusted: true,
});
const trustedTools = trusted.loader
.getExtensions()
.extensions.flatMap((extension) => [...extension.tools.keys()]);
assert.equal(trustedTools.includes("global_fixture"), true);
assert.equal(trustedTools.includes("project_fixture"), true);
});
});
test("alternate standalone cwd only uses explicit saved trust", async () => {
await withTempDir(async (directory) => {
const parentCwd = path.join(directory, "parent");
const childCwd = path.join(directory, "alternate");
const agentDir = path.join(directory, "agent");
await mkdir(parentCwd, { recursive: true });
await mkdir(childCwd, { recursive: true });
assert.equal(
resolveStandaloneChildProjectTrust({
parentCwd,
childCwd: parentCwd,
parentTrusted: true,
agentDir,
}),
true,
);
assert.equal(
resolveStandaloneChildProjectTrust({
parentCwd,
childCwd,
parentTrusted: true,
agentDir,
}),
false,
);
new ProjectTrustStore(agentDir).set(childCwd, true);
assert.equal(
resolveStandaloneChildProjectTrust({
parentCwd,
childCwd,
parentTrusted: false,
agentDir,
}),
true,
);
});
});
test("shutdown helper balances hooks and disposal despite errors", async () => {
let emits = 0;
let disposals = 0;
const session: DisposableChildSession = {
extensionRunner: {
hasHandlers: () => true,
async emit(event: SessionShutdownEvent) {
emits++;
assert.deepEqual(event, { type: "session_shutdown", reason: "quit" });
throw new Error("fixture shutdown failure");
},
},
dispose() {
disposals++;
},
};
await Promise.all([
shutdownAndDisposeChildSession(session),
shutdownAndDisposeChildSession(session),
shutdownAndDisposeChildSession(session),
]);
assert.equal(emits, 1);
assert.equal(disposals, 1);
});
test("shutdown helper bounds a stuck hook before disposal", async () => {
let disposals = 0;
const session: DisposableChildSession = {
extensionRunner: {
hasHandlers: () => true,
emit: () => new Promise(() => {}),
},
dispose() {
disposals++;
},
};
await shutdownAndDisposeChildSession(session, { timeoutMs: 10 });
assert.equal(disposals, 1);
});
+148
View File
@@ -0,0 +1,148 @@
import * as path from "node:path";
import {
DefaultResourceLoader,
getAgentDir,
ProjectTrustStore,
SettingsManager,
type AgentSession,
type SessionShutdownEvent,
} from "@earendil-works/pi-coding-agent";
const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
/** Tools that headless children must not receive. Everything else stays enabled. */
export const CHILD_EXCLUDED_TOOL_NAMES = [
"subagent_spawn",
"subagent_wait",
"subagent_cancel",
"subagent_check",
"subagent_list",
"workflow",
"ask_user",
] as const;
/** Fresh SDK options avoid turning the denylist into an accidental allowlist. */
export function childToolPolicy() {
return { excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES] };
}
export interface ChildResourceOptions {
cwd: string;
projectTrusted: boolean;
appendSystemPrompt?: string[];
agentDir?: string;
}
/** Load normal global/package resources and trust-gated project resources. */
export async function createChildResources(options: ChildResourceOptions) {
const agentDir = options.agentDir ?? getAgentDir();
const settingsManager = SettingsManager.create(options.cwd, agentDir, {
projectTrusted: options.projectTrusted,
});
const loader = new DefaultResourceLoader({
cwd: options.cwd,
agentDir,
settingsManager,
...(options.appendSystemPrompt
? { appendSystemPrompt: options.appendSystemPrompt }
: {}),
});
await loader.reload();
return { loader, settingsManager };
}
/**
* Same-directory children inherit the live parent decision. An alternate cwd
* is trusted only when Pi's persisted trust store explicitly trusts it (or a
* containing directory); unreadable/invalid trust data fails closed.
*/
export function resolveStandaloneChildProjectTrust(options: {
parentCwd: string;
childCwd: string;
parentTrusted: boolean;
agentDir?: string;
}) {
if (path.resolve(options.childCwd) === path.resolve(options.parentCwd)) {
return options.parentTrusted;
}
try {
const trustStore = new ProjectTrustStore(options.agentDir ?? getAgentDir());
return trustStore.get(options.childCwd) === true;
} catch {
return false;
}
}
/** Start child extension session hooks/resources in headless print mode. */
export async function bindChildSessionExtensions(
session: Pick<AgentSession, "bindExtensions">,
) {
await session.bindExtensions({ mode: "print" });
}
interface ChildExtensionRunner {
hasHandlers(eventType: string): boolean;
emit(event: SessionShutdownEvent): Promise<unknown>;
}
export interface DisposableChildSession {
readonly extensionRunner: ChildExtensionRunner;
dispose(): void;
}
const childShutdowns = new WeakMap<object, Promise<void>>();
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
return Promise.race([
operation.then(
() => undefined,
() => undefined,
),
timeout,
])
.catch(() => {})
.finally(() => {
if (timer) clearTimeout(timer);
});
}
/**
* Emit child session_shutdown once, then dispose once. Hook failures and a
* bounded hook deadline never prevent disposal.
*/
export function shutdownAndDisposeChildSession(
session: DisposableChildSession,
options: { timeoutMs?: number } = {},
) {
const existing = childShutdowns.get(session);
if (existing) return existing;
const shutdown = (async () => {
try {
if (session.extensionRunner.hasHandlers("session_shutdown")) {
await waitBounded(
session.extensionRunner.emit({
type: "session_shutdown",
reason: "quit",
}),
options.timeoutMs ?? CHILD_SHUTDOWN_TIMEOUT_MS,
);
}
} catch {
// Extension runner inspection/emission is best-effort during teardown.
} finally {
try {
session.dispose();
} catch {
// Disposal is terminal and must remain idempotent for callers.
}
}
})();
childShutdowns.set(session, shutdown);
return shutdown;
}
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
contextPercent,
formatContextUtilization,
} from "./context-utilization.ts";
test("formats current context occupancy against model capacity", () => {
assert.equal(
formatContextUtilization({ tokens: 26_040, contextWindow: 372_000 }),
"7%/372k",
);
});
test("formats the latest post-compaction usage rather than prior cumulative usage", () => {
const latestUsage = { tokens: 18_000, contextWindow: 200_000 };
assert.equal(formatContextUtilization(latestUsage), "9%/200k");
});
test("clamps over-capacity and nonsensical token values", () => {
assert.equal(
contextPercent({ tokens: 500_000, contextWindow: 200_000 }),
100,
);
assert.equal(
formatContextUtilization({
tokens: Number.POSITIVE_INFINITY,
contextWindow: 200_000,
}),
"?%/200k",
);
assert.equal(
formatContextUtilization({ tokens: -1, contextWindow: 200_000 }),
"?%/200k",
);
});
test("handles missing usage or capacity without NaN or Infinity", () => {
assert.equal(
formatContextUtilization({ tokens: null, contextWindow: 372_000 }),
"?%/372k",
);
assert.equal(formatContextUtilization({ tokens: 12_000 }), "");
assert.equal(
formatContextUtilization({ tokens: 12_000, contextWindow: 0 }),
"",
);
});
+47
View File
@@ -0,0 +1,47 @@
/** Compact, defensive context-window utilization formatting for child agents. */
export interface ContextUtilization {
/** Current conversation context occupancy; null while it is unknown after compaction. */
tokens?: number | null;
/** Capacity of the model currently serving the conversation. */
contextWindow?: number | null;
}
function usableTokens(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined;
}
function usableCapacity(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: undefined;
}
export function contextPercent(usage: ContextUtilization) {
const tokens = usableTokens(usage.tokens);
const capacity = usableCapacity(usage.contextWindow);
if (tokens === undefined || capacity === undefined) return undefined;
return Math.round(Math.min(100, Math.max(0, (tokens / capacity) * 100)));
}
export function formatCompactTokens(count: number) {
if (count < 1000) return Math.round(count).toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
/**
* Render `%/capacity`. If occupancy is temporarily unknown (notably directly
* after compaction), retain the useful model capacity as `?%/capacity`. If no
* valid capacity is available, omit the statistic because no percentage can
* be computed.
*/
export function formatContextUtilization(usage: ContextUtilization) {
const capacity = usableCapacity(usage.contextWindow);
if (capacity === undefined) return "";
const percent = contextPercent(usage);
return `${percent === undefined ? "?" : percent}%/${formatCompactTokens(capacity)}`;
}
+117
View File
@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
createToolCallTimeoutGuard,
runWithToolCallTimeout,
CHILD_TOOL_CALL_TIMEOUT_MS,
ToolCallTimeoutError,
} from "./tool-call-timeout.ts";
test("the production timeout error names the tool and three-minute limit", () => {
assert.equal(
new ToolCallTimeoutError("fixture_tool", CHILD_TOOL_CALL_TIMEOUT_MS)
.message,
'Tool call "fixture_tool" timed out after 3 minutes.',
);
});
test("a hung tool call fails clearly and receives an abort signal", async () => {
let executionSignal: AbortSignal | undefined;
await assert.rejects(
runWithToolCallTimeout("hung_fixture", 10, undefined, (signal) => {
executionSignal = signal;
return new Promise(() => {});
}),
(error: unknown) => {
assert.equal(error instanceof ToolCallTimeoutError, true);
assert.equal(
error instanceof Error ? error.message : "",
'Tool call "hung_fixture" timed out after 10 ms.',
);
return true;
},
);
assert.equal(executionSignal?.aborted, true);
assert.equal(executionSignal?.reason instanceof ToolCallTimeoutError, true);
});
test("parent cancellation still stops the timeout wrapper immediately", async () => {
const controller = new AbortController();
const reason = new Error("cancelled fixture");
const pending = runWithToolCallTimeout(
"hung_fixture",
60_000,
controller.signal,
() => new Promise(() => {}),
);
controller.abort(reason);
await assert.rejects(pending, (error: unknown) => error === reason);
});
test("the guard wraps each definition once and can discover later tools", () => {
const definitions = new Map<string, ToolDefinition>();
const createDefinition = (name: string): ToolDefinition => ({
name,
label: name,
description: name,
parameters: Type.Object({}),
async execute() {
return { content: [{ type: "text", text: "done" }], details: {} };
},
});
const first = createDefinition("first");
definitions.set(first.name, first);
const registry = {
getAllTools: () => [...definitions.keys()].map((name) => ({ name })),
getToolDefinition: (name: string) => definitions.get(name),
};
const guard = createToolCallTimeoutGuard(10);
const firstExecute = first.execute;
guard.apply(registry);
const firstWrappedExecute = first.execute;
assert.notEqual(firstWrappedExecute, firstExecute);
const second = createDefinition("second");
const secondExecute = second.execute;
definitions.set(second.name, second);
guard.apply(registry);
assert.equal(first.execute, firstWrappedExecute);
assert.notEqual(second.execute, secondExecute);
});
test("successful and terminating tool results pass through unchanged", async () => {
const result = {
content: [{ type: "text" as const, text: "recorded" }],
details: { value: "fixture" },
terminate: true,
};
assert.equal(
await runWithToolCallTimeout(
"structured_output",
10,
undefined,
async () => result,
),
result,
);
});
test("the timeout is fresh for each tool call, not shared across calls", async () => {
const execute = () =>
runWithToolCallTimeout("slow_fixture", 100, undefined, async () => {
await new Promise((resolve) => setTimeout(resolve, 60));
return "done";
});
assert.equal(await execute(), "done");
assert.equal(await execute(), "done");
});
+104
View File
@@ -0,0 +1,104 @@
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
export const CHILD_TOOL_CALL_TIMEOUT_MS = 3 * 60 * 1_000;
interface ToolRegistry {
getAllTools(): Array<{ name: string }>;
getToolDefinition(name: string): ToolDefinition | undefined;
}
function formatTimeout(timeoutMs: number) {
if (timeoutMs % 60_000 === 0) {
const minutes = timeoutMs / 60_000;
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
if (timeoutMs % 1_000 === 0) {
const seconds = timeoutMs / 1_000;
return `${seconds} second${seconds === 1 ? "" : "s"}`;
}
return `${timeoutMs} ms`;
}
export class ToolCallTimeoutError extends Error {
constructor(toolName: string, timeoutMs: number) {
super(
`Tool call "${toolName}" timed out after ${formatTimeout(timeoutMs)}.`,
);
this.name = "ToolCallTimeoutError";
}
}
export async function runWithToolCallTimeout<T>(
toolName: string,
timeoutMs: number,
signal: AbortSignal | undefined,
execute: (signal: AbortSignal) => Promise<T>,
) {
const timeoutController = new AbortController();
const executionSignal = signal
? AbortSignal.any([signal, timeoutController.signal])
: timeoutController.signal;
const timeoutError = new ToolCallTimeoutError(toolName, timeoutMs);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(timeoutError);
timeoutController.abort(timeoutError);
}, timeoutMs);
});
let removeAbortListener: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
if (!signal) return;
const onAbort = () => {
reject(
signal.reason instanceof Error
? signal.reason
: new Error(`Tool call "${toolName}" was aborted.`),
);
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
});
try {
return await Promise.race([execute(executionSignal), timeout, aborted]);
} finally {
if (timer) clearTimeout(timer);
removeAbortListener?.();
}
}
/**
* Wrap every currently registered child tool with an independent execution
* timeout. Calling apply() again is safe and picks up tools registered later.
*/
export function createToolCallTimeoutGuard(
timeoutMs = CHILD_TOOL_CALL_TIMEOUT_MS,
) {
const wrapped = new WeakSet<ToolDefinition>();
const wrap = (definition: ToolDefinition) => {
if (wrapped.has(definition)) return;
wrapped.add(definition);
const execute = definition.execute;
definition.execute = async (toolCallId, params, signal, onUpdate, ctx) =>
runWithToolCallTimeout(definition.name, timeoutMs, signal, (signal) =>
execute.call(definition, toolCallId, params, signal, onUpdate, ctx),
);
};
return {
apply(session: ToolRegistry) {
for (const { name } of session.getAllTools()) {
const definition = session.getToolDefinition(name);
if (definition) wrap(definition);
}
},
};
}
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
BTW_TITLE_MAX_LENGTH,
deriveBtwTitle,
isModelVisible,
} from "./src/by-the-way.ts";
test("deriveBtwTitle uses the first non-empty line and bounds the title", () => {
assert.equal(
deriveBtwTitle("\n Why does this work? \nignore me"),
"Why does this work?",
);
assert.equal(deriveBtwTitle(" \n\t"), "by the way");
const title = deriveBtwTitle("x".repeat(BTW_TITLE_MAX_LENGTH + 10));
assert.equal(title.length, BTW_TITLE_MAX_LENGTH);
assert.equal(title, `${"x".repeat(BTW_TITLE_MAX_LENGTH - 1)}…`);
const emojiTitle = deriveBtwTitle(
`${"x".repeat(BTW_TITLE_MAX_LENGTH - 2)}😀 more`,
);
assert.equal(emojiTitle, `${"x".repeat(BTW_TITLE_MAX_LENGTH - 2)}😀…`);
});
test("only model-origin snapshots are visible to model-facing tools", () => {
assert.equal(isModelVisible({ origin: "model" }), true);
assert.equal(isModelVisible({ origin: "btw" }), false);
});
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Effect } from "effect";
import { SubagentManager } from "./src/manager.ts";
import { claudeBackend } from "./src/backends/claude.ts";
import type { ParentContext, SpawnTask } from "./src/domain.ts";
import { createSubagentRuntime, runTool } from "./src/runtime.ts";
const parent: ParentContext = {
parentCwd: process.cwd(),
projectTrusted: false,
};
function task(prompt: string): SpawnTask {
return {
prompt,
title: "live Claude test",
cwd: process.cwd(),
model: "haiku",
reasoningEffort: "off",
parent,
};
}
async function claudeAvailable() {
return Effect.runPromise(claudeBackend.available);
}
/** Rejecting deadline so a hung wait still reaches finally() and disposes. */
function deadline<A>(operation: Promise<A>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`Live Claude test exceeded ${timeoutMs}ms`)),
timeoutMs,
);
});
return Promise.race([operation, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
test(
"Claude backend completes a live manager run",
{ timeout: 60_000 },
async (t) => {
if (!(await claudeAvailable())) {
t.skip("Claude Code executable is unavailable");
return;
}
const runtime = createSubagentRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
const started = await runTool(
runtime,
manager.spawn("claude", task("Reply with exactly: hello claude")),
);
await deadline(runTool(runtime, manager.waitFor([started.id])), 45_000);
const done = manager.view.get(started.id);
assert.equal(done?.status, "done");
assert.match(done?.finalText ?? "", /hello claude/i);
assert.ok(done?.meta.nativeSessionId);
assert.ok(done?.meta.sessionFilePath?.endsWith(".jsonl"));
} finally {
await runtime.dispose();
}
},
);
test(
"Claude backend interrupt settles a live run as aborted",
{ timeout: 60_000 },
async (t) => {
if (!(await claudeAvailable())) {
t.skip("Claude Code executable is unavailable");
return;
}
const runtime = createSubagentRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
const started = await runTool(
runtime,
manager.spawn(
"claude",
task(
"Write a detailed 10,000-word essay about the history of computing.",
),
),
);
// Wait for streamed output so cancellation definitely lands mid-run and
// exercises the SDK's normal interrupt receipt/result path.
const streamDeadline = Date.now() + 15_000;
while (
manager.view.get(started.id)?.status === "running" &&
!manager.view.get(started.id)?.liveAssistant?.text &&
Date.now() < streamDeadline
) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(manager.view.get(started.id)?.status, "running");
assert.ok(manager.view.get(started.id)?.liveAssistant?.text);
const report = await deadline(
runTool(runtime, manager.cancel([started.id])),
20_000,
);
assert.equal(report[0]?.cancelled, true);
assert.equal(manager.view.get(started.id)?.status, "error");
assert.equal(manager.view.get(started.id)?.errorText, "Run was aborted");
} finally {
await runtime.dispose();
}
},
);
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Effect } from "effect";
import { codexBackend } from "./src/backends/codex.ts";
import type { ParentContext, SpawnTask } from "./src/domain.ts";
import { SubagentManager } from "./src/manager.ts";
import { createSubagentRuntime, runTool } from "./src/runtime.ts";
const parent: ParentContext = {
parentCwd: process.cwd(),
projectTrusted: false,
};
function task(prompt: string): SpawnTask {
return {
prompt,
title: "live Codex test",
cwd: process.cwd(),
parent,
};
}
function deadline<A>(operation: Promise<A>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`Live Codex test exceeded ${timeoutMs}ms`)),
timeoutMs,
);
});
return Promise.race([operation, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
async function codexAvailable() {
return Effect.runPromise(codexBackend.available);
}
test(
"Codex backend completes a live manager run",
{ timeout: 75_000 },
async (t) => {
if (!(await codexAvailable())) {
t.skip("codex executable is unavailable");
return;
}
const runtime = createSubagentRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
const spawned = await runTool(
runtime,
manager.spawn("codex", task("Reply with exactly: hello codex")),
);
await deadline(runTool(runtime, manager.waitFor([spawned.id])), 60_000);
const done = manager.view.get(spawned.id);
assert.equal(done?.status, "done");
assert.match(done?.finalText ?? "", /hello codex/i);
assert.equal(done?.meta.backend, "codex");
assert.ok(done?.meta.nativeSessionId);
assert.ok(done?.meta.sessionFilePath);
} finally {
await runtime.dispose();
}
},
);
test(
"Codex backend interrupt settles a live manager run",
{ timeout: 30_000 },
async (t) => {
if (!(await codexAvailable())) {
t.skip("codex executable is unavailable");
return;
}
const runtime = createSubagentRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
const spawned = await runTool(
runtime,
manager.spawn(
"codex",
task("Run `sleep 30`, then reply with the word finished."),
),
);
await new Promise((resolve) => setTimeout(resolve, 250));
const result = await deadline(
runTool(runtime, manager.cancel([spawned.id])),
10_000,
);
assert.equal(result[0]?.cancelled, true);
assert.equal(manager.view.get(spawned.id)?.status, "error");
assert.equal(manager.view.get(spawned.id)?.errorText, "Run was aborted");
} finally {
await runtime.dispose();
}
},
);
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import { contextOccupancyTokens } from "./src/backends/claude.ts";
import { parseThreadTokenUsage } from "./src/backends/codex.ts";
// --- Claude: per-request occupancy, never the run aggregate ------------------
test("Claude occupancy sums one request's input, cache, and output tokens", () => {
assert.equal(
contextOccupancyTokens({
input_tokens: 12,
cache_read_input_tokens: 45_000,
cache_creation_input_tokens: 3_000,
output_tokens: 700,
}),
48_712,
);
});
test("Claude occupancy treats null cache/output counts as zero", () => {
assert.equal(
contextOccupancyTokens({
input_tokens: 9_000,
cache_read_input_tokens: null,
cache_creation_input_tokens: null,
output_tokens: 250,
}),
9_250,
);
});
test("Claude occupancy is unknown without a usable per-request usage", () => {
assert.equal(contextOccupancyTokens(undefined), undefined);
assert.equal(contextOccupancyTokens(null), undefined);
assert.equal(
contextOccupancyTokens({ input_tokens: null, output_tokens: 5 }),
undefined,
);
});
test("Claude occupancy from the last request stays below the window where the run aggregate would not", () => {
// A 10-request tool loop over a mostly-cached 150k prompt: the aggregate
// usage (what SDKResultMessage.usage reports) re-counts the cache per
// request and blows past the 200k window; the final request's usage is the
// true occupancy.
const perRequest = {
input_tokens: 500,
cache_read_input_tokens: 150_000,
cache_creation_input_tokens: 0,
output_tokens: 400,
};
const aggregate = {
input_tokens: 500 * 10,
cache_read_input_tokens: 150_000 * 10,
cache_creation_input_tokens: 0,
output_tokens: 400 * 10,
};
const occupancy = contextOccupancyTokens(perRequest);
assert.ok(occupancy !== undefined && occupancy < 200_000);
const misleading = contextOccupancyTokens(aggregate);
assert.ok(misleading !== undefined && misleading > 200_000);
});
// --- Codex: last request's total, never the thread-cumulative total ----------
const codexParams = (tokenUsage: unknown) => ({
threadId: "t",
turnId: "u",
tokenUsage,
});
test("Codex occupancy uses tokenUsage.last.totalTokens, not the cumulative total", () => {
const { tokens, contextWindow } = parseThreadTokenUsage(
codexParams({
total: {
totalTokens: 1_450_000,
inputTokens: 1_400_000,
cachedInputTokens: 1_300_000,
outputTokens: 50_000,
reasoningOutputTokens: 20_000,
},
last: {
totalTokens: 61_000,
inputTokens: 60_000,
cachedInputTokens: 55_000,
outputTokens: 1_000,
reasoningOutputTokens: 400,
},
modelContextWindow: 272_000,
}),
);
assert.equal(tokens, 61_000);
assert.equal(contextWindow, 272_000);
});
test("Codex occupancy is unknown when last usage or window is absent", () => {
assert.deepEqual(
parseThreadTokenUsage(
codexParams({ total: { totalTokens: 10 }, modelContextWindow: null }),
),
{ tokens: undefined, contextWindow: undefined },
);
assert.deepEqual(parseThreadTokenUsage({ threadId: "t" }), {
tokens: undefined,
contextWindow: undefined,
});
});
+568
View File
@@ -0,0 +1,568 @@
# subagents — Design Plan
A pi extension that fires off background subagents from a parent pi session, where each
subagent can be powered by one of three backends — **pi** (in-process SDK session),
**Claude Code** (`@anthropic-ai/claude-agent-sdk`), or **Codex** (`codex app-server`) —
unified behind a single Effect v4 service interface.
> **Status:** this document describes the original v1 plan (stubbed backends). All
> three backends are now REAL implementations — see `src/backends/{pi,claude,codex}.ts`.
> The stub machinery survives in `src/backends/stub.ts` for the manager test registry.
**Scope of the first version:** interface design + stubbed backend internals + the v1 UI
carried over. No real Claude/Codex process integration yet; the pi backend may also stay
stubbed initially so the manager/UI/tool loop can be exercised end to end with zero
external dependencies.
**Location:** `/Users/davis/.pi/agent/extensions/subagents/` — fully self-contained
(no imports from `../shared` or `../subagents`; the handful of shared helpers v1 uses are
copied in).
---
## 1. V1 inventory (what must be preserved)
Source: `/Users/davis/.pi/agent/extensions/subagents/` (`index.ts`, `manager.ts`,
`prompt.ts`, `result-delivery.ts`, `takeover.ts`) plus `../shared/` helpers.
### 1.1 Tools exposed to the parent LLM
| Tool | Parameters | Behavior |
|---|---|---|
| `subagent_spawn` | `prompt`, `title`, `working_dir?`, `model?`, `provider?`, `reasoning_effort?` | Fire-and-forget spawn. Returns immediately with an id (`sa-N`). Enforces `MAX_RUNNING = 4` with a synchronous reservation so parallel tool calls can't race past the cap. Validates `working_dir`, resolves model against the registry (inherit parent model/thinking level by default), truncates title to 160 chars. |
| `subagent_wait` | `ids[]` (max 64) | Blocks until all listed subagents settle; respects the tool `AbortSignal`; streams `Waiting for ...` via `onUpdate`. Marks the awaited results "consumed" so they are not also auto-delivered. Output budgets: 48KB total, 16KB per agent, with per-section fallbacks (`[omitted: ...]`). Errors on unknown ids (lists known ids). |
| `subagent_cancel` | `ids[]` | Aborts running subagents (marks consumed first to avoid duplicate delivery), waits for settlement, reports per-id `Cancelled ...` / `was already <status>`. Partial transcripts remain on disk. |
| `subagent_check` | `id` | Non-blocking peek: status line, turn count, error text, up to 2KB/20 lines of latest output (includes the live streaming assistant message). Does not consume the result. |
| `subagent_list` | — | One `describeSubagent()` line per agent: `id [status] "title" (provider/model, ctx%, elapsed, cwd)`. |
Prompt metadata (all strings live in `prompt.ts`): `subagent_spawn` has a
`promptSnippet` and two `promptGuidelines` (delegate self-contained tasks; don't block on
`subagent_wait` unless necessary). Tool descriptions explain fire-and-forget semantics,
the concurrency cap, and that children can't orchestrate/see the parent conversation.
### 1.2 State tracking (v1 `SubagentManager`)
- Plain class with `Map<string, Subagent>`; each `Subagent` = `{ id, title, prompt, cwd,
session: AgentSession, status: "running" | "done" | "error", createdAt, settledAt?,
errorText?, unsubscribeLifecycle }`.
- Children are **in-process pi `AgentSession`s** created via the SDK
(`createAgentSession` + `SessionManager.create(cwd)` → real session files visible in
`/resume`), with child resources loaded per-cwd (`DefaultResourceLoader`, trust-gated
project resources) and a tool denylist (`excludeTools`: the subagent_* tools,
`workflow`, `ask_user`).
- Settlement is driven by session lifecycle events (`agent_start` re-marks running;
`agent_settled` settles). Failure detection: thrown prompt error, last assistant
`stopReason === "error" | "aborted"`, error text bounded to 4096 chars.
- Change notification: `addChangeListener()` + `nextChange(signal)` promise — used by
`waitFor`, the footer status, and the dashboard.
- `waitFor(ids, signal, onPending)` keeps a `waitInterest` refcount per id so settles
during an active wait are marked consumed.
- `send(sub, text)`: steer via `session.steer()` while streaming, else start a fresh
`prompt()` run (used by takeover).
- Caps and cleanup: `MAX_RUNNING = 4`, `MAX_TRACKED = 64` with LRU pruning of settled
agents, `STOP_TIMEOUT_MS = 5s` bounded aborts, force-dispose fallback, idempotent
`disposeAll()` on `session_shutdown`.
### 1.3 Result delivery back to the parent
- When a child settles **unconsumed**, `onSettled` defers it into a tiny
`createDeferredResultDelivery` buffer (defer/consume/drain/clear keyed by id).
- Flush happens when the parent goes idle: immediately if `sessionContext.isIdle()`,
otherwise on the parent's `agent_settled` event. A later `subagent_wait` can still
consume a deferred result before flush (that's why it is a buffer, not an immediate
send).
- Delivery = `pi.sendMessage({ customType: "subagent-result", content, display: true,
details: { id, title, status } }, { deliverAs: "followUp", triggerTurn: true })`.
Content is built by `buildSubagentResultMessage` (`Subagent sa-N "title"
finished/failed.` + optional `Error:` line + output truncated to 24KB/600 lines with a
pointer to the child session file for the full transcript).
### 1.4 UI (carried over into v2 essentially as-is)
1. **Footer status** (`ctx.ui.setStatus("subagents", ...)`): `subagents: ■ 2 running ·
■ 1 done · ■ 1 failed · /subagents to view` (warning/success/error colored squares;
cleared when no subagents). Driven by manager change listener.
2. **`subagent-result` message renderer**: status icon (`■`/`x`) + bold accent header
`subagent sa-N · title · finished/failed`; collapsed = first 8 body lines +
`... (ctrl+o to expand)`; expanded = header + `Markdown` component render of the body.
3. **`/subagents` command** → `openSubagentPicker` loop (TUI mode only; notifies and
bails in non-TUI or when there are no subagents):
- **SubagentDashboard** — fullscreen overlay (`anchor: "center", width: "100%",
maxHeight: "100%"`), bordered list panel titled `agents · settled/total`. Each row:
selection marker `❯`, status glyph, title, dim id on the left; model id · context
utilization (`%/capacity`) · elapsed · status word on the right. Scroll window
centered on the selection with `... N more` markers. 1Hz ticker re-render for
elapsed/token columns + manager change subscription. Keys: `tui.select.up/down`
**and** `j`/`k` to move, `tui.select.confirm` to take over, `x` to abort the
selected running agent, `tui.select.cancel` to close. Hint line shows the
*configured* keys via `keybindings.getKeys()`.
- **TakeoverView** — fullscreen overlay for one subagent: header line (status glyph,
`id · title · status · elapsed · provider/model · ctx%`), fixed-height transcript
viewport (error line and scroll indicator consume viewport rows so height never
jumps), an `Input` line, and a hint row. Keys: `tui.input.submit` send (steer if
streaming, new run if idle), `app.interrupt`/`tui.select.cancel` back to dashboard,
`app.clear` abort run, `tui.editor.cursorUp/Down` scroll ±6 lines,
`tui.editor.pageUp/Down` page. Renders are throttled to 50ms because streaming can
emit per-token events.
- **Transcript rendering** (`buildTranscriptLines`): sanitizes ANSI/tabs/control
chars; user messages as `> ` accent-prefixed wrapped lines; assistant text wrapped
plain; thinking as dim italic `~ ` lines; tool calls as `→ toolname {args}`; tool
results as one dim `output:`/red `error:` first line. Includes the **live streaming
assistant message**, **live tool executions** (running/done/error marker + first
output line preview, tracked from `tool_execution_*` events until the final tool
result message lands), and **queued steering/follow-up messages** (`> [queued
steer] ...`) so Enter visibly acknowledges input.
**V2 requirement:** all of the above renders from a *normalized* per-subagent view
instead of poking at `sub.session` directly — that is the main UI refactor, everything
else ports over mostly verbatim.
---
## 2. Backend integration facts
These shape the interface even though v1-of-v2 stubs the internals. (Traced from the
"T3 Code" codebase which integrates Codex and Claude Code.)
| | Interactive sessions | One-shot tasks | Event shape | Interrupt | Steering |
|---|---|---|---|---|---|
| **pi** | In-process `createAgentSession()` (pi SDK); real session files; `session.subscribe()` | `session.prompt()` and read final assistant message (or `pi -p` subprocess, not needed) | `AgentSessionEvent` (message_start/update/end, tool_execution_*, agent_start/settled, queue_update, ...) | `session.abort()` | `session.steer()` / `followUp()` |
| **Claude Code** | `@anthropic-ai/claude-agent-sdk` `query()` — SDK launches the `claude` executable and streams JSON messages (assistant/user/result/system, streaming partials, tool_use blocks) | `claude -p --output-format json` | SDK message stream (async iterable) | `query.interrupt()` / abort controller | streaming-input mode: push more user messages into the input iterable |
| **Codex** | spawn `codex app-server` child process, JSON-RPC over stdin/stdout (`newConversation` / `sendUserTurn`, notifications: `agentMessageDelta`, `execCommandBegin/End`, `taskComplete`, `tokenCount`, ...) | `codex exec` (prints result to stdout, `--json` for events) | JSON-RPC notifications | `interruptConversation` request | send another `sendUserTurn` on the same conversation |
Common denominator all three can supply:
- an async event stream with: lifecycle (started/turn/settled), assistant text
(deltas and/or completed messages), reasoning text, tool execution begin/update/end,
token usage, errors;
- a way to send a follow-up/steering user message into a live session;
- an interrupt operation;
- a final result text per run;
- metadata: backend name, model identifier, session/log file path (pi session file,
Claude session id + projects dir JSONL, Codex rollout path), working dir.
That is exactly what the normalized event model below encodes.
---
## 3. Architecture
### 3.1 Effect v4 conventions used
- Single `effect` package (v4 beta). Services defined with
`ServiceMap.Service<T>()("id", ...)` / `ServiceMap.Key`; wiring via `Layer`;
`ManagedRuntime.make(layer)` at the extension edge with `runtime.runPromise(effect,
{ signal })` inside `async execute()` tool handlers and `await runtime.dispose()` on
`session_shutdown`.
- `Effect.gen` generators throughout the internals. `async`/`Promise` appears **only**
in: tool `execute()` bodies, `pi.on(...)` handlers, the `/subagents` command handler,
and the imperative TUI component classes (which are callback-driven, not effectful).
- Streams: `Stream<SubagentEvent>` per subagent, produced by backends
(`Stream.callback` for push-based sources like JSON-RPC notifications / SDK
iterables), consumed by a manager fiber per subagent.
- Errors: tagged error classes (`Data.TaggedError`) — `SpawnError`, `BackendUnavailable`,
`SubagentNotFound`, `ConcurrencyLimitError`, `SendError`, `InterruptTimeout`. Tool
handlers map these to thrown `Error`s with the same user-facing messages v1 uses.
### 3.2 Domain model (`src/domain.ts`)
```ts
type BackendName = "pi" | "claude" | "codex";
type SubagentStatus = "running" | "done" | "error"; // unchanged from v1
interface SpawnTask {
prompt: string;
title: string;
cwd: string;
// Generic model hint; each backend interprets/validates it its own way.
model?: string; // pi: "provider/model-id"; claude: model alias; codex: model slug
reasoningEffort?: string; // pi thinking level; codex reasoning effort; claude: ignored/mapped
parentContext: { // resolved by the tool layer, passed opaquely
parentCwd: string;
projectTrusted: boolean;
inheritedModelRef?: { provider: string; id: string }; // pi only
inheritedThinkingLevel?: string;
};
}
interface SubagentMeta {
backend: BackendName;
modelLabel?: string; // "anthropic/claude-opus-4-5", "gpt-5-codex", ...
contextWindow?: number; // for utilization %, when known
sessionFilePath?: string; // pi session file / claude JSONL / codex rollout path
nativeSessionId?: string; // claude session id, codex conversation id
}
```
### 3.3 Normalized event model (`src/domain.ts`)
One discriminated union covers everything the v1 UI and manager need. Backends translate
their native streams into this; nothing downstream knows which backend produced it.
```ts
type SubagentEvent =
// lifecycle
| { _tag: "RunStarted" } // pi agent_start / claude init / codex turn start
| { _tag: "RunSettled"; outcome: RunOutcome } // terminal per run (a session can run again via send())
// transcript building blocks
| { _tag: "UserMessage"; text: string } // initial prompt + takeover sends, echoed by backend
| { _tag: "AssistantDelta"; kind: "text" | "thinking"; delta: string }
| { _tag: "AssistantMessage"; parts: TranscriptPart[] } // finalized message (replaces live buffer)
| { _tag: "ToolStart"; toolId: string; name: string; argsPreview?: string }
| { _tag: "ToolUpdate"; toolId: string; outputPreview?: string }
| { _tag: "ToolEnd"; toolId: string; isError: boolean; outputPreview?: string }
// bookkeeping
| { _tag: "QueueChanged"; queued: ReadonlyArray<{ text: string; kind: "steer" | "follow-up" }> }
| { _tag: "UsageChanged"; tokens?: number; contextWindow?: number }
| { _tag: "MetaChanged"; meta: Partial<SubagentMeta> } // model switched, session file known, ...
| { _tag: "BackendError"; message: string }; // non-fatal diagnostics (fatal → RunSettled outcome)
type RunOutcome =
| { _tag: "Completed"; finalText: string }
| { _tag: "Failed"; errorText: string; partialText?: string }
| { _tag: "Interrupted"; partialText?: string };
type TranscriptPart =
| { type: "text"; text: string }
| { type: "thinking"; text: string; redacted?: boolean }
| { type: "toolCall"; toolId: string; name: string; argsPreview?: string };
```
Mapping sanity check against what the v1 UI renders:
| v1 render source | v2 event source |
|---|---|
| `sub.session.messages` (user/assistant/toolResult) | fold of `UserMessage` / `AssistantMessage` / `ToolEnd` into the snapshot transcript |
| streaming message (`agent.state.streamingMessage`) | live buffer fed by `AssistantDelta` (cleared on `AssistantMessage`/`RunSettled`) |
| live tool map from `tool_execution_*` | `ToolStart/Update/End` (entries drop when the finalized assistant/tool item lands, same as v1) |
| queued steer/follow-up messages | `QueueChanged` |
| status / errorText / settledAt | `RunStarted` / `RunSettled` |
| model + context utilization columns | `MetaChanged` + `UsageChanged` |
| `finalOutput` / `latestOutput` for check/wait/result delivery | `RunOutcome.finalText` (+ live buffer for `latestOutput`) |
Previews (`argsPreview`, `outputPreview`) are pre-flattened single-line strings because
the UI only ever shows one sanitized line — this avoids leaking three different native
"tool result" shapes through the interface.
### 3.4 The `SubagentBackend` service (`src/backend.ts`)
One interface; three implementations; a registry keyed by `BackendName`.
```ts
interface SubagentBackend {
readonly name: BackendName;
readonly capabilities: {
steering: boolean; // can send() into a live run (all three eventually; stubs: true)
modelSelection: boolean;
reasoningEffort: boolean;
};
/** Probe availability (binary on PATH, SDK importable, API key). Cheap + cached. */
readonly available: Effect.Effect<boolean>;
/**
* Spawn a session. Scoped: releasing the scope interrupts/kills the underlying
* session/process. Returns a live handle immediately (fire-and-forget semantics
* live in the manager, not here).
*/
spawn(task: SpawnTask): Effect.Effect<SubagentSession, SpawnError, Scope.Scope>;
}
interface SubagentSession {
readonly meta: Effect.Effect<SubagentMeta>; // snapshot; also updated via MetaChanged
/** All activity. Single consumer (the manager). Ends after the final RunSettled on close. */
readonly events: Stream.Stream<SubagentEvent, never>;
/** Steer while running, or start a fresh run when idle (v1 `manager.send` semantics). */
send(text: string): Effect.Effect<void, SendError>;
/** Interrupt the active run; resolves when the backend acknowledges. Bounded by the caller. */
readonly interrupt: Effect.Effect<void>;
/** Resolves with the outcome of the most recent run (mirrors the last RunSettled). */
readonly awaitSettled: Effect.Effect<RunOutcome>;
}
// Registry: a plain ServiceMap.Key holding a ReadonlyMap<BackendName, SubagentBackend>,
// built by a Layer that collects the three backend layers. Adding a 4th backend = one
// new file + one line in the registry layer.
class BackendRegistry extends ServiceMap.Key<BackendRegistry,
ReadonlyMap<BackendName, SubagentBackend>>()("subagents/BackendRegistry") {}
```
Design choices worth calling out:
- **`spawn` is scoped, not paired with an explicit `dispose`.** The manager opens one
`Scope` per subagent and closes it on cancel/prune/disposeAll — this replaces v1's
`shutdownAndDisposeChildSession` + WeakMap-idempotence machinery with Effect's own
guaranteed-once finalization. Timeout-bounded teardown (`5s`, then force) is a
finalizer concern inside each backend.
- **`send` unifies steer/new-run.** v1's `manager.send` already had these semantics; the
interface keeps the decision inside the backend because "is a run active" is
backend-native state.
- **Events, not message arrays, are the contract.** The manager folds events into
snapshots; backends never expose native message types. The pi backend has the richest
native data and simply down-converts.
- **No `exec()` one-shot operation in v1 of this extension** — see Open Questions; the
interface deliberately leaves room to add `exec(task): Effect<RunOutcome>` later
without touching the manager.
### 3.5 `SubagentManager` service (`src/manager.ts`)
Owns the registry of running/finished subagents. Effect service; internally a `Ref` (or
plain mutable map guarded by the single-threaded JS model) of entries plus a
**synchronous read model** for the TUI.
```ts
interface SubagentEntry {
id: string; // "sa-N", same scheme as v1
backend: BackendName;
title: string; prompt: string; cwd: string;
scope: Scope.Closeable; // owns the SubagentSession
session: SubagentSession;
eventPump: Fiber.Fiber<void>; // folds events → snapshot, fires settle hooks
}
interface SubagentSnapshot { // what the UI and tools read; plain immutable data
id: string; backend: BackendName; title: string; cwd: string;
status: SubagentStatus;
createdAt: number; settledAt?: number;
errorText?: string;
meta: SubagentMeta;
usage: { tokens?: number; contextWindow?: number };
transcript: ReadonlyArray<TranscriptItem>; // finalized items
liveAssistant?: { text: string; thinking: string };
liveTools: ReadonlyArray<LiveToolState>; // v1's LiveToolEvent, verbatim
queued: ReadonlyArray<{ text: string; kind: "steer" | "follow-up" }>;
finalText: string; // last Completed finalText (v1 finalOutput)
latestText: string; // finalText or live buffer (v1 latestOutput)
turns: number; // count of AssistantMessage events (for subagent_check)
}
class SubagentManager extends ServiceMap.Key<SubagentManager, {
spawn(backend: BackendName, task: SpawnTask):
Effect.Effect<SubagentSnapshot, SpawnError | ConcurrencyLimitError | BackendUnavailable>;
waitFor(ids: string[], onPending?: (pending: string[]) => void):
Effect.Effect<void, SubagentNotFound>; // interruption = tool signal abort
cancel(ids: string[]): Effect.Effect<CancelReport, SubagentNotFound>;
send(id: string, text: string): Effect.Effect<void, SubagentNotFound | SendError>;
get(id: string): Effect.Effect<SubagentSnapshot | undefined>;
list: Effect.Effect<ReadonlyArray<SubagentSnapshot>>;
disposeAll: Effect.Effect<void>;
/** Synchronous read model for the TUI (see 3.6). */
readonly view: SubagentReadModel;
}>()("subagents/SubagentManager") {}
```
Behavior preserved from v1, expressed in Effect terms:
- **Concurrency cap**: `MAX_RUNNING = 4` enforced with a synchronous
reserve-before-first-yield counter (same race-avoidance rationale as v1); the cap
counts *running* agents across all backends (see Open Questions for per-backend caps).
- **Settlement**: the per-subagent event pump fiber updates the snapshot on every event;
on `RunSettled` it computes `status`/`errorText` (bounded to 4096 chars) and invokes
the settle hook with `consumed = waitInterest > 0`. `waitFor` keeps the same
wait-interest refcounts (a `Ref<Map<string, number>>`) and wakes on snapshot changes
(a `Latch`/`PubSub`-based "next change" primitive replacing v1's resolver array).
- **Cancel**: mark consumed → `session.interrupt` with 5s bound → close scope on
timeout → wait for settle. Same "already \<status\>" reporting.
- **Pruning**: `MAX_TRACKED = 64`, oldest settled non-wait-interested entries pruned by
closing their scopes; cleanup tracked so `disposeAll` can await it.
- **Settle → result delivery hook**: the manager exposes `onSettled` wiring identical in
spirit to v1: the extension layer registers a callback that defers into the
`result-delivery` buffer and flushes on parent idle / `agent_settled`. The
`createDeferredResultDelivery` module is copied over unchanged (it is pure and already
has a test).
### 3.6 Synchronous read model for the TUI (`src/read-model.ts`)
The TUI components (`Component` classes with `render(width)`/`handleInput(data)`) are
imperative and render synchronously — they cannot `yield*` effects. Bridge:
```ts
interface SubagentReadModel {
list(): ReadonlyArray<SubagentSnapshot>; // sync snapshot reads
get(id: string): SubagentSnapshot | undefined;
subscribe(listener: () => void): () => void; // any-change notification (dashboard, footer)
subscribeTo(id: string, l: () => void): () => void; // per-agent (takeover view)
// sync fire-and-forget commands, executed via the ManagedRuntime under the hood:
requestSend(id: string, text: string): void; // TakeoverView input submit
requestAbort(id: string): void; // dashboard `x`, takeover app.clear
}
```
The manager's event pump writes each new snapshot into this store (plain mutable map +
listener set) as its last step, so the UI is always at most one microtask behind the
Effect world and v1's dashboard/takeover code ports with only these substitutions:
| v1 | v2 |
|---|---|
| `manager.list()` / `manager.get(id)` | `view.list()` / `view.get(id)` |
| `manager.addChangeListener` | `view.subscribe` |
| `sub.session.subscribe(handleSessionEvent)` + local live-tool map | `view.subscribeTo(id, ...)` + read `snapshot.liveTools` / `liveAssistant` (the fold moved into the manager) |
| `manager.send(sub, text)` / `manager.abort(sub)` | `view.requestSend(id, text)` / `view.requestAbort(id)` |
| `buildTranscriptLines(sub, ...)` reading `session.messages` | `buildTranscriptLines(snapshot, ...)` reading `snapshot.transcript` + live state + `snapshot.queued` |
Keybindings, layout math, scroll behavior, 1Hz ticker, 50ms render throttle, sanitize
logic: copied as-is.
### 3.7 Extension edge (`index.ts` + `src/runtime.ts`)
```
Layer graph:
PiBackendStub.layer ─┐
ClaudeBackendStub.layer ─┼→ BackendRegistry.layer ─→ SubagentManager.layer ─→ AppLayer
CodexBackendStub.layer ─┘
```
- `const runtime = ManagedRuntime.make(AppLayer)` — created lazily on first use (per the
extension-docs guidance to not start background resources in the factory;
`ManagedRuntime` builds its layer on first run, which satisfies this, but we still
gate creation behind `session_start`). `session_shutdown`: `runtime.runPromise(
manager.disposeAll)` then `await runtime.dispose()`, then recreate on the next
`session_start` (handles `/new`, `/resume`, `/reload`).
- **Tool handlers are the async boundary.** Each `execute(toolCallId, params, signal,
onUpdate, ctx)` builds one `Effect.gen` program and runs it with
`runtime.runPromise(program, { signal })`; tool-visible errors are converted from
tagged errors to `Error` messages matching v1 wording. `onUpdate` and `ctx`
(model registry, cwd, trust) are captured into the program as plain values/callbacks.
- **Tool schema change:** `subagent_spawn` gains
`agent: StringEnum(["pi", "claude", "codex"])` (optional, default `"pi"`), and
`model`/`provider`/`reasoning_effort` keep their v1 shapes but are documented as
backend-interpreted (pi validates against the registry; claude/codex validate against
their own known-model rules — stubs accept anything). `describeSubagent` lines and the
dashboard gain the backend name (e.g. `sa-3 [running] "title" (codex, gpt-5-codex,
41%/272k, 1m32s, /repo)`).
- `pi.registerMessageRenderer("subagent-result", ...)`, `pi.registerCommand(
"subagents", ...)`, footer status updates, and the result-delivery flush hooks
(`agent_settled`, idle-check on settle) are wired exactly like v1 — these all live
outside the runtime and call into it only via `runPromise`/the read model.
### 3.8 What the stubs do (v1 of this extension)
All three backends share a `createStubSession(profile)` helper (`src/backends/stub.ts`)
that fakes a plausible session so the manager, tools, result delivery, and both TUI
views are exercised end to end:
- **spawn**: emits `MetaChanged` (backend-flavored model label + fake session file path
under `os.tmpdir()`, e.g. `.../subagents-stub/sa-1.jsonl`, actually written with the
transcript so "full transcript in session file" pointers resolve), then `RunStarted`,
then a scripted turn: 2–3 `AssistantDelta` batches on a timer (~200ms cadence so
streaming is visible), one fake `ToolStart/Update/End` cycle (`bash` with an args
preview), `UsageChanged` ramping tokens, a final `AssistantMessage`, and `RunSettled`
with `Completed` — final text echoes the task: `"[stub:claude] completed: <first 200
chars of prompt>"`. Total runtime ~3–6s (configurable per profile) so `subagent_wait`,
the footer counters, and the dashboard's running→done transition are observable.
- **send**: emits `UserMessage` + `QueueChanged` (briefly, to exercise the queued-line
rendering) and runs another scripted turn — so takeover steering works.
- **interrupt**: stops the script timer and settles with `Interrupted` (→ status
`error`, errorText `"Run was aborted"`, matching v1) — so `subagent_cancel` and the
`x`/`app.clear` keybindings work.
- **failure path**: a magic prompt prefix (e.g. `FAIL:`) makes the run settle with
`Failed` — so error rendering, `errorText` rows, and failed result delivery are
testable without real backends.
- **Backend differentiation**: per-backend profiles vary the model label
(`anthropic/claude-opus-4-5` vs `claude-sonnet-4-5` vs `gpt-5-codex`), fake context
window, tool names, and delta cadence — enough to verify the UI treats backends
uniformly. `available` returns `true` for stubs (real impls will probe binaries/SDK).
- The **pi stub** can later be swapped for the real in-process SDK implementation by
porting v1's `manager.ts` session code behind the same `SubagentSession` shape; that
port is the first post-stub milestone.
---
## 4. File/module layout
```
/Users/davis/.pi/agent/extensions/subagents/
├── package.json # name, "effect": "^4.0.0-beta.x"; pi extension entry via pi.extensions
├── package-lock.json / node_modules/ (after npm install)
├── docs/
│ └── design-plan.md # this document
├── index.ts # extension factory: runtime lifecycle, 5 tools, /subagents
│ # command, message renderer, footer status, result flush hooks
└── src/
├── domain.ts # BackendName, SubagentStatus, SpawnTask, SubagentEvent,
│ # RunOutcome, TranscriptItem/Part, SubagentSnapshot, tagged errors
├── backend.ts # SubagentBackend + SubagentSession interfaces, BackendRegistry
│ # key + registry layer
├── backends/
│ ├── stub.ts # shared scripted fake-session machinery
│ ├── pi.ts # PiBackend layer (v1: stub profile; later: real pi SDK sessions)
│ ├── claude.ts # ClaudeBackend layer (v1: stub; later: @anthropic-ai/claude-agent-sdk)
│ └── codex.ts # CodexBackend layer (v1: stub; later: codex app-server JSON-RPC)
├── manager.ts # SubagentManager service + layer: registry, cap, waitFor,
│ # cancel, prune, settle hook, event-fold into snapshots
├── read-model.ts # sync SubagentReadModel bridge for the TUI
├── runtime.ts # AppLayer composition + ManagedRuntime create/dispose helpers
├── result-delivery.ts # deferred delivery buffer (copied from v1, unchanged)
├── result-delivery.test.ts
├── prompt.ts # all model-facing strings (v1 copy + `agent` param description)
├── format.ts # elapsed/context-utilization/activity-status formatting
│ # (merged copies of ../shared/{context-utilization,activity-status}.ts)
└── ui/
├── transcript.ts # sanitize + buildTranscriptLines over SubagentSnapshot
└── takeover.ts # SubagentDashboard + TakeoverView + openSubagentPicker (ported)
```
Notes:
- `package.json` is needed because `effect` is an npm dependency (extension-with-deps
style from the extension docs). Everything else avoids new dependencies.
- v1's `child-session.ts` trust/tool-policy helpers are **not** copied in v1 of v2 (the
stubs don't need them); the real pi backend will bring the needed subset into
`backends/pi.ts` when implemented. The `resolveStandaloneChildProjectTrust` logic *is*
still referenced by the design (SpawnTask.parentContext.projectTrusted) so the tool
layer computes trust the same way v1 does.
- Suggested project scripts (per house rules, to be added): `check` (`tsc --noEmit`),
`test` (`node --test` or vitest for `result-delivery` + manager fold tests against
stub backends).
---
## 5. Migration/coexistence note
v1 and v2 register the same tool names (`subagent_spawn`, ...) and the same
`/subagents` command. While both live in `~/.pi/agent/extensions/`, pi will suffix
duplicate commands (`/subagents:1`, `/subagents:2`) and both tool sets would be
registered. During development, either (a) v2 uses temporary names
(`subagent2_spawn`, `/subagents2`), or (b) v1 is moved out of the auto-discovery dir.
Recommendation: (a) during development, rename to final names when v2 replaces v1.
---
## 6. Open questions (need user input)
1. **Per-backend spawn options.** v1's `model`/`provider`/`reasoning_effort` are
pi-shaped. Options: (a) keep one generic `model` string + `reasoning_effort` that
each backend interprets (proposed above — simplest for the LLM); (b) add a
`backend_options` free-form object; (c) per-backend defaults in a config file with no
per-spawn override. Which surface do you want the parent LLM to have?
2. **One-shot exec mode.** Should the interface expose a separate cheap
`exec(task): Effect<RunOutcome>` (mapping to `codex exec` / `claude -p
--output-format json` / a fresh in-memory pi session), or is one interactive-session
path enough? Exec would forfeit takeover/steering for that subagent — is a
`mode: "session" | "exec"` spawn parameter desirable, or backend-internal
optimization only?
3. **Permissions/sandboxing for Claude/Codex children.** Subagents are headless, so
interactive permission prompts are impossible. Do we run Claude with
`bypassPermissions`/`--dangerously-skip-permissions` and Codex with
`--full-auto`-style sandbox + never-ask approval policy? Should this be a global
extension setting, per-spawn, or hardcoded? (Pi children inherit v1's trust-store
logic — keep that as-is?)
4. **Concurrency cap scope.** Keep one global `MAX_RUNNING = 4`, or per-backend caps
(e.g. 4 pi + 2 claude + 2 codex)? Global is proposed as default.
5. **Steering support parity in real backends.** Codex steering means
interrupt-then-new-turn or queued `sendUserTurn`; Claude requires streaming-input
mode from the start. OK to declare `capabilities.steering` and have the TakeoverView
input show "(steering not supported)" if a backend can't, or is steering a hard
requirement for all three?
6. **Model/thinking inheritance across backends.** When `agent: "claude"` and no model
given, what's the default (e.g. always `opus`/`sonnet`)? Inheriting the parent pi
model is meaningless cross-backend. Proposal: per-backend default model in a small
config block; confirm.
7. **Binary/SDK discovery + failure UX.** When `codex`/`claude` isn't installed or has
no credentials, should `subagent_spawn` fail fast with a clear tool error (proposed),
or should the backends be hidden from the `agent` enum dynamically?
8. **Result truncation budgets.** Keep v1's numbers (24KB result message, 48KB wait
total, 16KB per agent, 2KB check preview) unchanged?
9. **Effect version pinning.** Effect v4 is beta — pin an exact `4.0.0-beta.x` and
accept manual bumps, or track the beta dist-tag?
10. **Persistence across reloads.** v1 loses all subagents on `session_shutdown`
(disposeAll). Codex/Claude children are external processes that *could* outlive a
pi reload — should v2 keep v1's kill-everything behavior (proposed for v1 of v2) or
plan for reattach later?
@@ -0,0 +1,354 @@
# Building a pi extension on the Effect v4 setup
> Practical, migration-oriented companion to [`effect-v4-notes.md`](./effect-v4-notes.md).
> That file is the Effect API cheat-sheet (v3→v4 renames, child processes, concurrency);
> **this** file is how to stand up _one pi extension_ on the same toolchain and where to
> draw the line between "wrap in Effect" and "leave as plain TS".
>
> **Verified against** `effect@4.0.0-beta.98`, `@effect/platform-node@4.0.0-beta.98`,
> `@effect/tsgo@0.19.0`, `typescript@7.0.2` (checked 2026-07-13, in `extensions/subagents`).
> `npm run check` there passes clean — use it as the reference implementation.
>
> Audience: the agents migrating `firecrawl-search`, `ask-user`, `model-info`,
> `git-info`, `ui-customization`, and `copy-all`.
---
## 0. The one rule: Effect is for the async core, not the whole file
A pi extension's public surface is **plain callbacks** the host calls:
`export default function (pi: ExtensionAPI)`, `pi.registerTool({ …, async execute() })`,
`pi.registerCommand`, `pi.on(...)`, renderers. None of that changes. Effect lives _inside_
those callbacks — you run an effect at the top of `execute` and return its value.
Reach for Effect only where you actually get something from it:
- **Yes:** async work that needs typed errors, cancellation via the tool `AbortSignal`,
timeouts, retries/polling, or a resource whose lifetime must outlive one call
(child process, subscription) → child processes (`git-info`, `copy-all`), the
Firecrawl SDK calls (`firecrawl-search`), git/gh polling (`git-info`).
- **No / barely:** pure TUI popups and rendering (`ask-user`, `ui-customization`),
cross-extension channel plumbing, cost/token bookkeeping (`model-info`). These are
synchronous or already-Promise UI code; wrapping them in Effect adds ceremony and no
safety. Migrate them by keeping the logic and only touching whatever genuinely async
part benefits (usually nothing).
If an extension has no async core worth typing, the "migration" may be just adopting the
toolchain (§1) and leaving the body plain. Don't invent an Effect layer to have one.
---
## 1. Per-extension toolchain (copy this exactly)
Each extension is its own npm package with its own `node_modules`. Replicate the pinned
setup — do **not** float the versions.
`package.json`:
```jsonc
{
"name": "<ext>",
"private": true,
"type": "module",
"scripts": {
"check": "tsc --noEmit -p .",
"prepare": "effect-tsgo patch", // patches the Effect LS into the tsgo binary
},
"dependencies": {
"effect": "4.0.0-beta.98", // EXACT pin, no ^
"@effect/platform-node": "4.0.0-beta.98", // only if you touch fs / child processes
},
"devDependencies": {
"@effect/tsgo": "^0.19.0",
"typescript": "^7.0.2",
},
}
```
`tsconfig.json` (extends the repo root, adds the Effect language-service plugin):
```jsonc
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"plugins": [{ "name": "@effect/language-service" }],
},
"include": ["index.ts", "src/**/*.ts", "*.test.ts"],
}
```
The root `tsconfig.json` already sets `strict`, `module`/`moduleResolution: NodeNext`,
`verbatimModuleSyntax`, `allowImportingTsExtensions`, `target: ES2022`, `types: ["node"]`.
Keep local `.ts` imports written **with the `.ts` extension** (`./src/manager.ts`), matching
the house style.
### The LSP / language-service, precisely
- `typescript@7` is the **native (tsgo) TypeScript** — its `tsc` is a Go binary, and it's
what `npm run check` runs.
- `@effect/language-service` is **not an installed npm package** here (you won't find it in
`node_modules`). It's delivered by `effect-tsgo patch`, run automatically by the `prepare`
lifecycle script on `npm install`. The patch injects the Effect Language Service into the
tsgo binary; the tsconfig `plugins` entry then turns on Effect-aware editor diagnostics and
quickfixes (e.g. "yield missing services", floating effects).
- Practical sequence for a fresh/edited extension: `npm install` (runs the patch) →
`npm run check`. If editor diagnostics look stale, re-run `npx effect-tsgo patch`;
`npx effect-tsgo get-exe-path` prints the patched binary it resolved.
- The plugin drives the _editor_; it does not change `tsc` exit codes. `npm run check` is
still your ground-truth green/red signal.
---
## 2. The async boundary: one `ManagedRuntime` + a `runTool` helper
Every extension that uses Effect needs exactly this shape. Build **one** runtime lazily,
dispose it on shutdown, and funnel every tool handler through a single helper that turns
Effect exits into what pi expects (a value, or a thrown `Error`; interruption → a friendly
message). This is lifted verbatim from `src/runtime.ts` — reuse it.
```ts
// src/runtime.ts
import { Cause, Exit, Layer, ManagedRuntime, type Effect } from "effect";
import { NodeServices } from "@effect/platform-node"; // only if you need fs / processes
// Compose your services here (see §3). NodeServices.layer =
// ChildProcessSpawner | FileSystem | Path | Crypto | Stdio | Terminal.
const AppLayer = Layer.mergeAll(NodeServices.layer /*, MyServiceLive */);
export function createRuntime() {
return ManagedRuntime.make(AppLayer);
}
export type ExtRuntime = ReturnType<typeof createRuntime>;
/** Run an effect from an async handler: value on success, thrown Error otherwise. */
export async function runTool<A, E>(
runtime: ExtRuntime,
effect: Effect.Effect<A, E>,
options: { signal?: AbortSignal; interruptMessage?: string } = {},
) {
const exit = await runtime.runPromiseExit(
effect,
options.signal ? { signal: options.signal } : undefined,
);
if (Exit.isSuccess(exit)) return exit.value;
if (Cause.hasInterruptsOnly(exit.cause)) {
throw new Error(options.interruptMessage ?? "Operation was aborted.");
}
const [first] = Cause.prettyErrors(exit.cause);
throw new Error(first?.message ?? Cause.pretty(exit.cause));
}
```
Wire it into the extension lifecycle (lazy build, dispose on shutdown):
```ts
// index.ts
export default function (pi: ExtensionAPI) {
let runtime: ExtRuntime | undefined;
const getRuntime = () => (runtime ??= createRuntime());
pi.registerTool({
name: "my_tool",
parameters: Type.Object({/* typebox */}),
async execute(_id, params, signal) {
return await runTool(getRuntime(), myEffect(params), {
signal,
interruptMessage: "Cancelled.",
});
},
});
pi.on("session_shutdown", async () => {
const closing = runtime;
runtime = undefined;
await closing?.dispose(); // runs all finalizers: kills scoped child processes, etc.
});
}
```
Key facts:
- `runPromiseExit(effect, { signal })` — passing the tool's `AbortSignal` makes cancelling
the tool interrupt the fiber; scoped resources (child processes) are torn down.
- `runtime.runFork(effect)` for fire-and-forget background work (polling loops, §5).
- `dispose()` is the single teardown hook — put resource cleanup in Effect finalizers, not
in ad-hoc `session_shutdown` code.
- If an extension has **no** services (pure `Effect.tryPromise` calls, no fs/process), you
can skip `NodeServices.layer` and even skip `ManagedRuntime` — `Effect.runPromiseExit(effect, { signal })`
works standalone. Add the runtime only when you have a `Layer` to share.
---
## 3. Services, layers, errors (house style)
Mirror the subagents code. Class-style `Context.Service`, `Layer.effect`/`Layer.sync`,
`Data.TaggedError` for domain errors.
```ts
import { Context, Data, Effect, Layer } from "effect";
// Domain error — yieldable, tag-narrowable with Effect.catchTag:
export class GitError extends Data.TaggedError("GitError")<{
readonly message: string;
}> {}
// Service: the class value is the key AND the type.
export interface GitShape {
readonly status: Effect.Effect<string, GitError>;
}
export class Git extends Context.Service<Git, GitShape>()("gitinfo/Git") {}
// Layer building it (Layer.effect can use scoped resources; Layer.sync for pure):
export const GitLive: Layer.Layer<Git, never, ChildProcessSpawner> =
Layer.effect(
Git,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner; // dependency, provided by NodeServices.layer
return Git.of({
status: spawner
.string(ChildProcess.make("git", ["status", "--porcelain"]))
.pipe(
Effect.mapError(
(cause) => new GitError({ message: String(cause) }),
),
),
});
}),
);
```
Provide dependencies with `Layer.provide`, compose with `Layer.mergeAll` (see §2 `AppLayer`).
Full rename table (`Effect.fork`→`forkChild`, `catchAll`→`catch`, `Either`→`Result`, …) and
the child-process / concurrency APIs live in `effect-v4-notes.md` — don't re-derive them.
---
## 4. Recipe: wrapping a Promise SDK (firecrawl-search)
The Firecrawl client is Promise-based. Wrap each call in `Effect.tryPromise` with a typed
error; the callback receives an `AbortSignal` tied to fiber interruption — forward it to any
SDK that accepts one so tool cancellation propagates.
```ts
import { Data, Effect } from "effect";
class FirecrawlError extends Data.TaggedError("FirecrawlError")<{
readonly cause: unknown;
}> {}
const search = (client: Firecrawl, query: string) =>
Effect.tryPromise({
try: (signal) => client.search(query, {/* …, signal? if supported */}),
catch: (cause) => new FirecrawlError({ cause }),
});
```
There's no `Layer` here unless you want the client as a service. For a single tool this is
fine to call directly through `runTool` (or even plain `Effect.runPromiseExit`, §2). Keep the
existing `.env`/API-key reading and result truncation as plain TS — no reason to Effect-ify
string trimming.
---
## 5. Recipe: child processes + timeout + polling (git-info, copy-all)
`git-info` shells out to `git`/`gh` with per-command timeouts and polls on an interval;
`copy-all` pipes text into `pbcopy`. Two viable levels — pick the lightest that fits.
**Simple, one-shot, small:** if all you do is "run a command, capture stdout, with a
timeout," the Effect win is `Effect.timeout` + interruption killing the child. Use the
spawner service:
```ts
import { Effect } from "effect";
import { ChildProcess } from "effect/unstable/process";
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
const gitStatus = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner;
return yield* spawner
.string(ChildProcess.make("git", ["status", "--porcelain"], { cwd }))
.pipe(Effect.timeout("3 seconds")); // fails with Cause.TimeoutError (tag "TimeoutError")
});
```
Note the import gotcha: `ChildProcessSpawner` the **class** comes from the
`.../ChildProcessSpawner` submodule; the `effect/unstable/process` index gives you the
namespace. Provide `NodeServices.layer` (or `ChildProcessSpawner.layer`). Full command
builder / streaming / kill semantics are in `effect-v4-notes.md §6`.
**Polling on an interval:** replace `setInterval` with a forked, scheduled effect so it
cancels cleanly on dispose:
```ts
import { Effect, Schedule } from "effect";
const pollLoop = refresh.pipe(
Effect.catchCause(() => Effect.void), // don't let one failure kill the loop
Effect.repeat(Schedule.spaced("3 seconds")),
);
const fiber = runtime.runFork(pollLoop); // interrupted by runtime.dispose()
```
**When to stay plain:** `copy-all` spawning `pbcopy` is a trivial one-shot with no
cancellation need — the existing `node:child_process` + Promise wrapper is honestly fine.
Migrate it only for consistency; if you do, `Effect.callback` around `child.once("exit", …)`
(see notes §4) is the minimal wrapper. Don't add a service/layer for a clipboard write.
---
## 6. Recipe: UI popups & rendering (ask-user, ui-customization, model-info)
These are the "leave it mostly plain" cases.
- `ask-user` is a TUI popup that resolves a Promise when the user picks. That Promise already
models the one async thing. Effect adds nothing; if you want uniformity, wrap the final
await in `Effect.tryPromise` at the boundary and stop there. Do **not** build a service.
- `ui-customization` and `model-info` are renderers / event bookkeepers driven by
`pi.on(...)` and cross-extension channels (`shared/dashboard-state.ts`). Channels are a
pi-native mechanism — keep them. State counting and formatting stay synchronous TS.
- If `model-info` has a periodic "live update" tick, the §5 polling pattern applies; but a
plain timer here is also acceptable since there's no resource to tear down.
The migration bar for these: adopt the toolchain (§1) so they typecheck under TS7 + the
Effect LS, and only touch runtime code that has a real async/resource concern.
---
## 7. Verify — scoped to the one extension
Run everything from inside the extension directory so you never trigger a root/global build
or format:
```bash
cd extensions/<ext>
npm install # first time / after dep or script edits — runs `prepare` (effect-tsgo patch)
npm run check # tsc --noEmit -p . ← ground-truth green/red
npm run test # only if the extension defines tests; keep them minimal
```
`extensions/subagents` is the known-green reference: `npm run check` there exits 0 against
the pinned versions. If a migrated extension fails `check` with `Effect.fork`/`ServiceMap`/
`Either` errors, it's using stale v3/early-beta APIs — consult the rename table in
`effect-v4-notes.md`.
---
## 8. Don'ts (keep it lean)
1. **Don't float versions.** Pin `effect` and `@effect/platform-node` to the exact same
`4.0.0-beta.98`; `unstable/*` can break between betas.
2. **Don't Effect-ify pure/UI code.** No service or layer for a clipboard write, a string
truncation, or a popup. §0 is the test: is there a typed-error / cancellation / resource /
retry concern? If not, leave it.
3. **Don't guess APIs.** If it's not in this guide or `effect-v4-notes.md` and you haven't
grepped it in `node_modules/effect/dist/*.d.ts`, don't write it. Watch for hallucinated
`ServiceMap` (reverted to `Context`), `Effect.fork` (→ `forkChild`), `Effect.either`
(→ `Effect.result`).
4. **Don't hand-roll teardown.** Put cleanup in Effect finalizers and let `runtime.dispose()`
in `session_shutdown` run them.
5. **Don't over-test.** A `check` that passes plus one focused runtime test (where behavior
is non-obvious) beats a wall of defensive unit tests.
6. **Don't run root scripts.** No repo-root `tsc`, `prettier`, or `npm run format`; stay
inside `extensions/<ext>`.
</content>
@@ -0,0 +1,571 @@
# Effect v4 — Practical Notes for the Subagents Extension
> **Building a new pi extension on this stack?** Start with the migration-oriented
> [`effect-v4-extension-guide.md`](./effect-v4-extension-guide.md) (toolchain setup, the
> `ManagedRuntime` + `runTool` boundary, when *not* to use Effect, per-extension recipes).
> This file is the deeper Effect API reference it points back to.
> **Verified against:** `effect@4.0.0-beta.98` and `@effect/platform-node@4.0.0-beta.98`
> (npm dist-tag `beta`, checked 2026-07-13). Every snippet in this doc was type-checked
> with `tsc --strict` against these packages, and the process-spawning / runtime snippets
> were executed with Node 24. v4 source lives in the **`Effect-TS/effect-smol`** repo
> (not `Effect-TS/effect`); the official migration guide is
> [`effect-smol/MIGRATION.md`](https://github.com/Effect-TS/effect-smol/blob/main/MIGRATION.md)
> plus the `migration/*.md` files next to it (including a machine-readable
> `migration/v3-to-v4.md` rename map).
## Install
```bash
npm install effect@beta @effect/platform-node@beta
```
Big structural facts:
- **One version number for everything.** All ecosystem packages release together at
`4.0.0-beta.N`. `@effect/platform-node` must match `effect` exactly.
- **`@effect/platform` is gone — merged into core `effect`.** `FileSystem`, `Path`,
`PlatformError`, `Terminal`, `Stdio` are now top-level `effect` modules.
`@effect/platform-node` remains as the Node *implementation* package.
- **`effect/unstable/*` namespace.** Modules that may break in minor releases:
`effect/unstable/process` (child processes — the one we need), `http`, `rpc`, `cli`,
`ai`, `workers`, etc. Everything outside `unstable/` follows strict semver.
- **`"type": "module"`**, ESM-first. Works with `moduleResolution: NodeNext` or `Bundler`.
- Runtime keep-alive is built in now: a fiber suspended on `Deferred.await` etc. keeps
the Node process alive without `NodeRuntime.runMain` (v3 needed runMain for that).
## Cheat sheet: v3 → v4 renames you will actually hit
| v3 | v4 |
| --- | --- |
| `Context.Tag` / `Effect.Service` | `Context.Service` (`Effect.Service` is **gone**) |
| `Effect.fork` | `Effect.forkChild` |
| `Effect.forkDaemon` | `Effect.forkDetach` |
| `Effect.async` | `Effect.callback` |
| `Effect.catchAll` | `Effect.catch` |
| `Effect.catchAllCause` / `catchAllDefect` | `Effect.catchCause` / `catchDefect` |
| `Effect.zipRight` / `zipLeft` | `Effect.andThen` / `Effect.tap` |
| `Effect.either` | `Effect.result` |
| `Either` module | `Result` (`Result.succeed` / `Result.fail`) |
| `Layer.scoped` | `Layer.effect` (all layers are scoped-capable now) |
| `Mailbox` | `Queue` (Queue absorbed Mailbox: done/fail signalling built in) |
| `FiberRef` | `Context.Reference` (module `effect/References`) |
| `Scope.extend` | `Scope.provide` |
| `Stream.async*` (all 4 variants) | `Stream.callback` |
| `Stream.fromChunk(s)` | `Stream.fromArray` / `Stream.fromArrays` (Chunk de-emphasized; arrays used) |
| `Schema.TaggedError` | `Schema.TaggedErrorClass` |
| `@effect/platform/Command` | `effect/unstable/process` → `ChildProcess` |
| `@effect/platform/CommandExecutor` | `effect/unstable/process` → `ChildProcessSpawner` |
| `@effect/platform/FileSystem` | `effect/FileSystem` |
| `Runtime.runPromise(runtime)(...)` | gone — use `ManagedRuntime` methods directly |
| `UnknownException` (tryPromise default) | `Cause.UnknownError` |
Removed with no replacement: `Effect.forkAll`, `Effect.forkWithErrorHandler`.
Early v4 betas renamed `Context` → `ServiceMap`; **that was reverted** — beta.98 uses
`Context` again (ignore older blog posts/AI answers mentioning `ServiceMap`).
---
## 1. Basics: `Effect.gen`, running, the Promise boundary
Unchanged in spirit from v3. `Effect<A, E, R>` = success `A`, typed error `E`,
required services `R`.
```ts
import { Effect, Exit } from "effect"
const double = (n: number) => Effect.succeed(n * 2)
const program = Effect.gen(function* () {
const a = yield* Effect.succeed(1)
const b = yield* double(a)
yield* Effect.log(`result: ${b}`)
return b
})
// Entry-point conversion — only at the outermost layer (tool handlers):
const p: Promise<number> = Effect.runPromise(program) // rejects on failure/defect
const pe: Promise<Exit.Exit<number, never>> = Effect.runPromiseExit(program) // never rejects
const s: number = Effect.runSync(program) // throws if async
const fiber = Effect.runFork(program) // fire-and-forget fiber
```
`runPromise` only accepts `Effect<A, E, never>` — all services must be provided first
(or use a `ManagedRuntime`, §8). `runPromiseExit` is the right choice inside tool
handlers when you want to convert failures to structured results instead of throws.
`Effect.fn` gives you named, traced effect functions (nice stack traces):
```ts
const spawnJob = Effect.fn("spawnJob")(function* (name: string) {
yield* Effect.log(`spawning ${name}`)
return name
})
// spawnJob("x") : Effect<string>
```
## 2. Services & Layers
`Context.Tag` and the v3 `Effect.Service` helper class are **both gone**. The single
API is `Context.Service`, in two forms:
```ts
import { Context, Effect, Layer } from "effect"
// Class-style (recommended — class value doubles as the key):
class Clock extends Context.Service<Clock, {
readonly now: Effect.Effect<number>
}>()("app/Clock") {}
// Function-style key:
const Random = Context.Service<{ next: Effect.Effect<number> }>("app/Random")
// Yielding the key retrieves the service (keys are Effects):
const use = Effect.gen(function* () {
const clock = yield* Clock
return yield* clock.now
})
```
Layers work like v3 (`Layer.scoped` merged into `Layer.effect` — every `Layer.effect`
build can use scoped resources):
```ts
const ClockLive = Layer.succeed(Clock, { now: Effect.sync(() => Date.now()) })
const RandomLive = Layer.effect(
Random,
Effect.gen(function* () {
yield* Effect.log("building Random")
return { next: Effect.sync(() => Math.random()) }
})
)
// Dependencies between layers:
class Ids extends Context.Service<Ids, { readonly nextId: Effect.Effect<string> }>()("app/Ids") {}
const IdsLive = Layer.effect(
Ids,
Effect.gen(function* () {
const random = yield* Random
return Ids.of({ nextId: Effect.map(random.next, (n) => `id-${n}`) })
})
)
const AppLayer = Layer.mergeAll(ClockLive, IdsLive.pipe(Layer.provide(RandomLive)))
const runnable = use.pipe(Effect.provide(AppLayer)) // R = never
// One-off service injection without a layer:
const withTestClock = use.pipe(Effect.provideService(Clock, { now: Effect.succeed(0) }))
```
Also useful:
- `Context.Reference("key", { defaultValue: () => ... })` — a service **with a default**
(this replaces `FiberRef`); no layer needed, override with `Effect.provideService`.
- **Layer memoization changed:** layers are memoized *globally across separate
`Effect.provide` calls* by default in v4 (per-runtime MemoMap), so providing the same
layer to two effects builds it once.
## 3. Error handling
`Data.TaggedError` survives unchanged; `Schema.TaggedError` is now
`Schema.TaggedErrorClass`. Errors are yieldable directly.
```ts
import { Data, Effect, Schema } from "effect"
class SpawnError extends Data.TaggedError("SpawnError")<{
readonly command: string
readonly cause: unknown
}> {}
class TimeoutError extends Data.TaggedError("TimeoutError")<{ readonly ms: number }> {}
// Schema-validated variant:
class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
id: Schema.Number
}) {}
const risky: Effect.Effect<string, SpawnError | TimeoutError> = Effect.gen(function* () {
if (somethingBad) return yield* new SpawnError({ command: "codex", cause: "enoent" })
return "ok"
})
// catchTag narrows the error union:
const handled: Effect.Effect<string, TimeoutError> = risky.pipe(
Effect.catchTag("SpawnError", (e) => Effect.succeed(`spawn failed: ${e.command}`))
)
const handledAll: Effect.Effect<string> = risky.pipe(
Effect.catchTags({
SpawnError: (e) => Effect.succeed(`spawn: ${e.command}`),
TimeoutError: (e) => Effect.succeed(`timeout ${e.ms}ms`)
})
)
```
Renames: `catchAll` → `Effect.catch`, `catchAllCause` → `catchCause`,
`catchAllDefect` → `catchDefect`, `tapErrorCause` → `tapCause`, `Effect.either` →
`Effect.result` (returns `Result`, the renamed `Either`). `Effect.match`,
`Effect.exit`, `Effect.orDie`, `Effect.mapError`, `Effect.tapError` all still exist.
The `Cause` data structure was **flattened** (no nested `Sequential`/`Parallel` trees;
a cause is now a flat list of failures — simpler to render in job status output).
## 4. Promise / async / callback interop
```ts
import { Cause, Data, Effect } from "effect"
// Promise that "can't" reject — rejection becomes a defect:
const a = Effect.promise(() => Promise.resolve(42))
// Promise that may reject — default error type is Cause.UnknownError (v3: UnknownException):
const b = Effect.tryPromise((signal) => fetch("https://x", { signal }))
// Typed error:
class HttpError extends Data.TaggedError("HttpError")<{ cause: unknown }> {}
const c = Effect.tryPromise({
try: (signal) => fetch("https://x", { signal }),
catch: (cause) => new HttpError({ cause })
})
```
Both forms receive an `AbortSignal` that fires on fiber interruption — pass it to SDKs
(claude SDK, fetch, etc.) so interrupting a subagent fiber cancels the underlying call.
Callback APIs — `Effect.async` is now **`Effect.callback`**:
```ts
// signature: Effect.callback<A, E, R>((resume, signal) => void | cleanupEffect)
const waitForExit = (child: import("node:child_process").ChildProcess) =>
Effect.callback<number>((resume) => {
child.once("exit", (code) => resume(Effect.succeed(code ?? -1)))
})
// cleanup on interruption via the AbortSignal:
const sleepy = Effect.callback<number>((resume, signal) => {
const t = setTimeout(() => resume(Effect.succeed(1)), 1000)
signal.addEventListener("abort", () => clearTimeout(t))
})
```
## 5. Scopes & resource management
Same shape as v3. Relevant rename: `Scope.extend` → `Scope.provide`.
```ts
import { Effect, Exit, Scope } from "effect"
const managedProc = Effect.acquireRelease(
acquireEffect, // acquire (uninterruptible)
(proc, exit) => Effect.sync(() => proc.kill()) // release, gets the Exit
)
// Scoped region: release runs when the region ends
const useIt = Effect.scoped(
Effect.gen(function* () {
const proc = yield* managedProc
return proc.pid
})
)
// addFinalizer:
Effect.scoped(Effect.gen(function* () {
yield* Effect.addFinalizer((exit) => Effect.log(`closing, ok=${Exit.isSuccess(exit)}`))
}))
```
**Key pattern for background subagents** — a manually-controlled scope so the process
outlives the spawning tool call, and killing the job = closing the scope:
```ts
const startJob = Effect.gen(function* () {
const scope = yield* Scope.make()
const handle = yield* Scope.provide(managedProc, scope) // resource lives in `scope`
// store `scope` in your job registry; later, from any fiber:
// yield* Scope.close(scope, Exit.void) // runs finalizers -> kills process
return { handle, scope }
})
```
`Effect.acquireUseRelease(acquire, use, release)` for one-shot bracketing.
## 6. Child processes & FileSystem (the important part)
The v3 `@effect/platform` `Command`/`CommandExecutor` modules were redesigned into
**`effect/unstable/process`** with `ChildProcess` (command builder) and
`ChildProcessSpawner` (the service). The Node implementation comes from
`@effect/platform-node`.
Import gotcha: `import { ChildProcessSpawner } from "effect/unstable/process"` gives you
the **module namespace**, not the service class. Import the class from the submodule:
```ts
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { NodeServices, NodeFileSystem } from "@effect/platform-node"
```
`NodeServices.layer` provides everything at once:
`ChildProcessSpawner | Crypto | FileSystem | Path | Stdio | Terminal`.
### Building commands
```ts
// Template literal form:
const cmd1 = ChildProcess.make`echo hello`
// Options + template:
const cmd2 = ChildProcess.make({ cwd: "/tmp" })`ls -la`
// Array form (best for dynamic args — no shell parsing):
const cmd = ChildProcess.make("codex", ["exec", "--json", prompt], {
cwd: workDir,
env: { CODEX_API_KEY: key }, // merged over process.env when extendEnv: true
extendEnv: true,
stdin: "pipe", // "pipe" | "inherit" | "ignore" | a Stream<Uint8Array>
stdout: "pipe", // "pipe" | "inherit" | "ignore" | a Sink
stderr: "pipe"
})
// pipelines: cmdA.pipe(ChildProcess.pipeTo(cmdB, { from: "stderr" }))
// modifiers: ChildProcess.setCwd, ChildProcess.setEnv, ChildProcess.prefix
```
Note `detached` defaults to **true on non-Windows** — the child gets its own process
group (good: killing it kills the group).
### Running: two styles
**Style A — via the spawner service (simple, non-interactive):**
```ts
const simple = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const text = yield* spawner.string(cmd) // full stdout as string
const lines = yield* spawner.lines(cmd) // string[]
const code = yield* spawner.exitCode(cmd) // ExitCode (branded number)
const lineStream = spawner.streamLines(cmd, { includeStderr: true }) // Stream<string>
})
```
**Style B — the Command IS an Effect.** Yielding a command spawns it and returns a
`ChildProcessHandle`, with the process lifetime tied to the ambient `Scope`
(`Command extends Effect<ChildProcessHandle, PlatformError, ChildProcessSpawner | Scope>`):
```ts
import { Effect, Stream } from "effect"
const streaming = Effect.scoped(
Effect.gen(function* () {
const handle = yield* cmd // <-- spawns; killed when scope closes
handle.pid // ProcessId (branded number)
// stdout is Stream<Uint8Array, PlatformError>; decode + split lines:
yield* handle.stdout.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Effect.log(`out: ${line}`))
)
// stdin is a Sink<void, Uint8Array>; write by running a stream into it:
yield* Stream.fromArray([new TextEncoder().encode("hello\n")]).pipe(
Stream.run(handle.stdin)
)
// handle.stderr, handle.all (stdout+stderr interleaved) also available
const code = yield* handle.exitCode // waits for exit
const running = yield* handle.isRunning
yield* handle.kill({ killSignal: "SIGTERM", forceKillAfter: "5 seconds" })
return code
})
)
```
Verified at runtime: interrupting a fiber that spawned a process inside its scope
(e.g. `Fiber.interrupt` on a fiber running `Effect.scoped(...)` around `sleep 30`)
kills the child process. This is the backbone of subagent cancellation.
There's also `handle.unref` for letting the parent exit independently, and
`additionalFds` for extra file descriptor channels (fd3+).
### FileSystem / Path (now core)
```ts
import { Effect, FileSystem, Path } from "effect"
const files = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const content = yield* fs.readFileString("/tmp/in.txt")
yield* fs.writeFileString(path.join("/tmp", "out.txt"), content)
})
// provide NodeFileSystem.layer, or NodeServices.layer for everything
```
## 7. Concurrency toolbox for a job manager
All verified compiling + running:
```ts
import {
Cause, Deferred, Effect, Fiber, FiberMap, PubSub, Queue, Ref, Stream, Exit
} from "effect"
const forking = Effect.gen(function* () {
const f1 = yield* Effect.forkChild(work) // v3 fork — dies with parent
const f2 = yield* Effect.forkScoped(work) // tied to ambient Scope
const f3 = yield* Effect.forkDetach(work) // v3 forkDaemon — outlives parent ✅ for background jobs
// all fork variants accept { startImmediately?: boolean, uninterruptible?: boolean | "inherit" }
const r: number = yield* Fiber.join(f1) // propagates failure
const exit: Exit.Exit<number> = yield* Fiber.await(f3) // failure as data
yield* Fiber.interrupt(f2)
// fiber.pollUnsafe() -> Exit | undefined for sync status checks
})
```
**Deferred** — one-shot completion signal (job finished):
```ts
const done = yield* Deferred.make<Result, JobError>()
yield* Effect.forkDetach(job.pipe(Effect.exit, Effect.flatMap((e) => Deferred.done(done, e))))
const result = yield* Deferred.await(done) // keeps Node process alive in v4, no runMain needed
```
**Ref** — shared job-table state: `Ref.make({})`, `Ref.update`, `Ref.get`, `Ref.set`.
(`SynchronizedRef` for effectful updates.)
**Queue** — absorbed v3's `Mailbox`: it can be ended/failed, and converts to a Stream.
To use `Queue.end`, the error type must include `Cause.Done`:
```ts
const q = yield* Queue.make<string, Cause.Done>() // capacity/strategy via options
yield* Queue.offer(q, "line")
yield* Queue.offerAll(q, ["a", "b"])
yield* Queue.end(q) // signal "no more items"
const asStream = Stream.fromQueue(q) // ends when queue ends
// Queue.take / takeAll / takeN / poll for direct consumption
```
**PubSub** — broadcast (e.g. multiple watchers of one job's output):
```ts
const ps = yield* PubSub.unbounded<string>()
const sub = yield* PubSub.subscribe(ps) // scoped acquisition
yield* PubSub.publish(ps, "event")
const msg = yield* PubSub.take(sub) // function call, not sub.take
// Stream.fromPubSub(ps) for stream consumption
```
**FiberMap / FiberSet / FiberHandle** — keyed fiber registries, ideal for a subagent
manager (auto-interrupts everything when its scope closes; adding under an existing key
interrupts the old fiber):
```ts
const jobsDemo = Effect.scoped(Effect.gen(function* () {
const jobs = yield* FiberMap.make<string>() // FiberMap<string> (keys)
yield* FiberMap.run(jobs, "job-1", work) // fork into the map
const fiber = yield* FiberMap.get(jobs, "job-1")
yield* FiberMap.remove(jobs, "job-1") // interrupts it
}))
```
**Stream** essentials for process output: `Stream.decodeText()`, `Stream.splitLines`,
`Stream.runForEach`, `Stream.runCollect`, `Stream.run(sink)`, `Stream.fromQueue`,
`Stream.fromPubSub`, `Stream.toAsyncIterable`, `Stream.callback` (v3 `Stream.async`),
`Stream.fromArray` (v3 `fromChunk` — v4 uses plain arrays, Chunk is de-emphasized).
Also `Stream.mkString` to collect into one string.
Combinators: `Effect.all([...], { concurrency: n })`, `Effect.race`,
`Effect.timeout("5 seconds")` (fails with `Cause.TimeoutError`, tag `"TimeoutError"`),
`Semaphore.make(n)` (moved out of `Effect.makeSemaphore`), `Latch.make()`.
## 8. ManagedRuntime — the entry-point pattern for the extension
v3's `Runtime.runPromise(runtime)(effect)` API is gone; `effect/Runtime` now only holds
`runMain` plumbing. **`ManagedRuntime` is the way** to share layers across async entry
points:
```ts
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { NodeServices } from "@effect/platform-node"
class JobStore extends Context.Service<JobStore, {
readonly list: Effect.Effect<Array<string>>
}>()("app/JobStore") {}
const JobStoreLive = Layer.sync(JobStore, () => ({ list: Effect.succeed([]) }))
const AppLayer = Layer.mergeAll(JobStoreLive, NodeServices.layer)
// Build ONCE at extension activation. Layers are built lazily on first run.
const runtime = ManagedRuntime.make(AppLayer)
// pi tool handler — the only place we touch Promises:
export async function handleToolCall() {
return await runtime.runPromise(
Effect.gen(function* () {
const store = yield* JobStore
return yield* store.list
})
)
}
// fire-and-forget background work from a sync/async context:
const fiber = runtime.runFork(backgroundEffect)
// extension deactivation — closes the runtime scope, runs ALL finalizers
// (i.e. kills any still-scoped child processes):
export async function shutdown() {
await runtime.dispose()
}
```
`ManagedRuntime` also exposes `runSync`, `runSyncExit`, `runPromiseExit`, `runCallback`,
a `memoMap` (share layer memoization between multiple runtimes), and `.scope`.
Prefer `runPromiseExit` in tool handlers if you want to map typed failures to
tool-result errors instead of catching thrown `Cause` wrappers.
---
## Architecture sketch for subagents
```
ManagedRuntime.make(Layer.mergeAll(NodeServices.layer, SubagentManagerLive))
│ built once at extension init; dispose() on shutdown
▼
SubagentManager service (Context.Service):
- Ref<HashMap<JobId, JobEntry>> job table
- start(cmd): Scope.make() → Scope.provide(ChildProcess handle, scope)
→ Effect.forkDetach(pump stdout → Queue<string, Cause.Done>)
→ Deferred<ExitCode> completed on exit
- status(id): Deferred.isDone / handle.isRunning / Ref lookup
- output(id): drain Queue (takeAll) or Stream.fromQueue for tailing
- kill(id): Scope.close(scope, Exit.void) → finalizer kills the process
Tool handlers: async fns calling runtime.runPromise(Effect.gen(...))
```
## Surprises & gotchas (learned the hard way)
1. **`effect/unstable/process` exports module namespaces.** The
`ChildProcessSpawner` class must be imported from
`"effect/unstable/process/ChildProcessSpawner"` (or use
`ChildProcessSpawner.ChildProcessSpawner` off the namespace).
2. **`Effect.fork` does not exist** — code (or an LLM) writing v3-style `Effect.fork`
fails to compile. Use `forkChild` / `forkScoped` / `forkDetach` / `forkIn`.
3. **`Queue.end` needs `Cause.Done` in the queue's error type** —
`Queue.make<A>()` defaults to `E = never` and won't accept `end`.
4. **Early-beta content mentioning `ServiceMap` is stale** — it was renamed back to
`Context` during the beta. Similarly, some AI-generated content mentions APIs that
never shipped.
5. **`Either` is `Result`**, `Effect.either` is `Effect.result`.
6. **`Data.TaggedError` errors are yieldable** — `yield* new SpawnError({...})` fails
the effect directly, no `Effect.fail` wrapper needed (works in v3 too, but idiomatic
in v4 docs).
7. **Chunk → Array**: stream element groups are plain arrays (`Stream.fromArray`,
`runCollect` returns `Array<A>`), not `Chunk`.
8. `tryPromise` default error is `Cause.UnknownError` (was `UnknownException`).
9. `unstable/*` modules can break between v4 minors — pin exact versions
(`4.0.0-beta.98`) and keep `effect` and `@effect/platform-node` in lockstep.
10. Scratch workspace with all verified test files: `/tmp/effect-v4-scratch`
(`test1-basics.ts` … `test8-runtime.ts`, `smoke.mts` executed successfully).
+746
View File
@@ -0,0 +1,746 @@
/**
* Subagents — spawn background subagents on one of three backends
* (pi, Claude Code, Codex) unified behind a single Effect service interface.
*
* Tools (for the parent LLM):
* - subagent_spawn: fire-and-forget spawn (prompt, title, agent, working_dir,
* model, reasoning_effort). Max 4 running at once across all backends.
* - subagent_wait: block until the listed subagents settle, return results.
* - subagent_cancel: stop one or more running subagents.
* - subagent_check: peek at a subagent's status and recent activity.
* - subagent_list: list all subagents.
*
* Unawaited subagents queue their result as a follow-up message when they
* settle. `/subagents` opens a picker + full interactive takeover view.
*
* Architecture: Effect v4 generators throughout (backends -> manager ->
* runtime); this file is the async boundary where tool handlers run effects
* against one shared ManagedRuntime. All three backends are real: pi runs
* in-process SDK sessions, claude drives the Claude Agent SDK, codex speaks
* JSON-RPC to a scoped `codex app-server` process.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { StringEnum } from "@earendil-works/pi-ai";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
ExtensionUIContext,
} from "@earendil-works/pi-coding-agent";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
getAgentDir,
getMarkdownTheme,
ProjectTrustStore,
truncateHead,
} from "@earendil-works/pi-coding-agent";
import { Markdown, Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { deriveBtwTitle, isModelVisible } from "./src/by-the-way.ts";
import {
BACKEND_NAMES,
formatElapsed,
latestText,
REASONING_EFFORTS,
type SubagentSnapshot,
} from "./src/domain.ts";
import {
formatActivityStatus,
formatContextUtilization,
} from "./src/format.ts";
import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
import {
buildSubagentResultMessage,
buildSubagentSpawnResult,
SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS,
SUBAGENT_CANCEL_TOOL_DESCRIPTION,
SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS,
SUBAGENT_CHECK_TOOL_DESCRIPTION,
SUBAGENT_LIST_TOOL_DESCRIPTION,
SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS,
SUBAGENT_SPAWN_PROMPT_GUIDELINES,
SUBAGENT_SPAWN_PROMPT_SNIPPET,
SUBAGENT_SPAWN_TOOL_DESCRIPTION,
SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS,
SUBAGENT_WAIT_TOOL_DESCRIPTION,
} from "./src/prompt.ts";
import { createDeferredResultDelivery } from "./src/result-delivery.ts";
import {
createSubagentRuntime,
runTool,
type SubagentRuntime,
} from "./src/runtime.ts";
import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts";
const SUBAGENT_OUTPUT_MAX_BYTES = 24 * 1024;
const WAIT_OUTPUT_MAX_BYTES = 48 * 1024;
const WAIT_PER_AGENT_MAX_BYTES = 16 * 1024;
interface BtwResultData {
readonly id: string;
readonly title: string;
readonly status: SubagentSnapshot["status"];
readonly errorText?: string;
readonly prompt: string;
readonly answer: string;
readonly sessionFilePath?: string;
}
function describeSubagent(snap: SubagentSnapshot) {
const details = [
`${snap.backend}: ${snap.meta.modelLabel ?? "?"}`,
formatContextUtilization(snap.usage),
formatElapsed(snap),
snap.cwd,
].filter(Boolean);
return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`;
}
function truncatedOutput(
snap: SubagentSnapshot,
maxBytes = SUBAGENT_OUTPUT_MAX_BYTES,
): string {
const output = snap.finalText || "(no output)";
const truncation = truncateHead(output, {
maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
maxLines: Math.min(600, DEFAULT_MAX_LINES),
});
let text = truncation.content;
if (truncation.truncated) {
text += `\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)} shown. Full transcript in session file: ${snap.meta.sessionFilePath ?? "?"}]`;
}
return text;
}
/**
* Same-directory children inherit the live parent decision. An alternate cwd
* is trusted only when pi's persisted trust store explicitly trusts it (or a
* containing directory); unreadable/invalid trust data fails closed.
*/
function resolveChildProjectTrust(options: {
parentCwd: string;
childCwd: string;
parentTrusted: boolean;
}) {
if (path.resolve(options.childCwd) === path.resolve(options.parentCwd)) {
return options.parentTrusted;
}
try {
const trustStore = new ProjectTrustStore(getAgentDir());
return trustStore.get(options.childCwd) === true;
} catch {
return false;
}
}
export default function (pi: ExtensionAPI) {
let runtime: SubagentRuntime | undefined;
let managerPromise: Promise<SubagentManagerShape> | undefined;
let sessionContext: ExtensionContext | undefined;
let ui: ExtensionUIContext | undefined;
let unsubStatus: (() => void) | undefined;
const resultDelivery = createDeferredResultDelivery<SubagentSnapshot>();
const getRuntime = () => (runtime ??= createSubagentRuntime());
/** Resolve the manager service once per runtime and wire the extension hooks. */
const getManager = () => {
managerPromise ??= getRuntime()
.runPromise(SubagentManager)
.then((manager) => {
manager.view.setOnSettled(onSettled);
unsubStatus?.();
unsubStatus = manager.view.subscribe(() => updateStatus(manager));
updateStatus(manager);
return manager;
});
return managerPromise;
};
const updateStatus = (manager: SubagentManagerShape) => {
if (!ui) return;
const subs = manager.view.list();
if (subs.length === 0) {
ui.setStatus("subagents", undefined);
return;
}
const running = subs.filter((snap) => snap.status === "running").length;
const failed = subs.filter((snap) => snap.status === "error").length;
const done = subs.length - running - failed;
ui.setStatus(
"subagents",
formatActivityStatus(ui.theme, { running, done, failed }),
);
};
const deliverResult = (snap: SubagentSnapshot) => {
pi.sendMessage(
{
customType: "subagent-result",
content: buildSubagentResultMessage({
id: snap.id,
title: snap.title,
status: snap.status,
errorText: snap.errorText,
output: truncatedOutput(snap),
}),
display: true,
details: { id: snap.id, title: snap.title, status: snap.status },
},
{ deliverAs: "followUp", triggerTurn: true },
);
};
const flushResults = () => {
for (const snap of resultDelivery.drain()) deliverResult(snap);
};
const deliverBtwResult = (snap: SubagentSnapshot) => {
// appendEntry is a synchronous SessionManager operation and emits an
// entry_appended event, so it is safe while the parent is streaming and
// never enters the model's context or follow-up queue.
pi.appendEntry<BtwResultData>("btw-result", {
id: snap.id,
title: snap.title,
status: snap.status,
errorText: snap.errorText,
prompt: snap.prompt,
answer: truncatedOutput(snap),
sessionFilePath: snap.meta.sessionFilePath,
});
ui?.notify(
snap.status === "error"
? `by the way “${snap.title}” failed — reopen it with /subagents`
: `by the way “${snap.title}” answered — reopen it with /subagents`,
snap.status === "error" ? "error" : "info",
);
};
const onSettled = (snap: SubagentSnapshot, consumed: boolean) => {
// A shutdown can settle children while disposing their scopes. Never
// append into a session whose extension runtime is already closing.
if (!sessionContext) return;
if (snap.origin === "btw") {
deliverBtwResult({ ...snap, meta: { ...snap.meta } });
return;
}
if (consumed) {
resultDelivery.consume([snap.id]);
return;
}
// Keep the result retractable while the parent is working. A later
// subagent_wait can consume it before agent_settled flushes follow-ups.
// Defer a copy: the live snapshot keeps mutating if the subagent is
// restarted before the deferred result flushes.
resultDelivery.defer({ ...snap, meta: { ...snap.meta } });
if (sessionContext?.isIdle()) flushResults();
};
pi.on("session_start", (_event, ctx) => {
sessionContext = ctx;
if (ctx.hasUI) ui = ctx.ui;
});
pi.on("agent_settled", flushResults);
pi.on("session_shutdown", async () => {
sessionContext = undefined;
resultDelivery.clear();
unsubStatus?.();
unsubStatus = undefined;
ui?.setStatus("subagents", undefined);
ui = undefined;
const closing = runtime;
runtime = undefined;
managerPromise = undefined;
// Disposing the runtime runs the manager finalizer, which tears down all
// subagent scopes (and, later, their real child processes).
await closing?.dispose();
});
// --- Tools -------------------------------------------------------------
pi.registerTool({
name: "subagent_spawn",
label: "Spawn Subagent",
description: SUBAGENT_SPAWN_TOOL_DESCRIPTION,
promptSnippet: SUBAGENT_SPAWN_PROMPT_SNIPPET,
promptGuidelines: SUBAGENT_SPAWN_PROMPT_GUIDELINES,
parameters: Type.Object({
prompt: Type.String({
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.prompt,
}),
name: Type.String({
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.name,
}),
harness: StringEnum(BACKEND_NAMES, {
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.harness,
}),
working_dir: Type.Optional(
Type.String({
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.workingDir,
}),
),
model: Type.Optional(
Type.String({
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.model,
}),
),
reasoning_effort: Type.Optional(
StringEnum(REASONING_EFFORTS, {
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort,
}),
),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const manager = await getManager();
const harness = params.harness;
const cwd = path.resolve(ctx.cwd, params.working_dir ?? ".");
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
throw new Error(`working_dir is not a directory: ${cwd}`);
}
const title = params.name.trim().slice(0, 160) || "subagent";
const snap = await runTool(
getRuntime(),
manager.spawn(harness, {
prompt: params.prompt,
title,
cwd,
model: params.model,
reasoningEffort: params.reasoning_effort,
parent: {
parentCwd: ctx.cwd,
projectTrusted: resolveChildProjectTrust({
parentCwd: ctx.cwd,
childCwd: cwd,
parentTrusted: ctx.isProjectTrusted(),
}),
inheritedModel: ctx.model
? { provider: ctx.model.provider, id: ctx.model.id }
: undefined,
inheritedThinkingLevel: pi.getThinkingLevel(),
modelRegistry: ctx.modelRegistry,
},
}),
{ signal, interruptMessage: "Subagent spawn aborted." },
);
return {
content: [
{
type: "text",
text: buildSubagentSpawnResult({
id: snap.id,
title: snap.title,
harness,
modelLabel: snap.meta.modelLabel ?? "?",
cwd,
}),
},
],
details: {
id: snap.id,
title: snap.title,
cwd,
harness,
model: snap.meta.modelLabel,
},
};
},
});
pi.registerTool({
name: "subagent_wait",
label: "Wait for Subagents",
description: SUBAGENT_WAIT_TOOL_DESCRIPTION,
parameters: Type.Object({
ids: Type.Array(Type.String(), {
maxItems: 64,
description: SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS.ids,
}),
}),
async execute(_toolCallId, params, signal, onUpdate) {
const manager = await getManager();
const ids = [...new Set(params.ids)];
if (ids.length === 0)
throw new Error("Provide at least one subagent id.");
const known = manager.view
.list()
.filter(isModelVisible)
.map((snap) => snap.id);
const unknown = ids.filter((id) => {
const snap = manager.view.get(id);
return !snap || !isModelVisible(snap);
});
if (unknown.length > 0) {
throw new Error(
`Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`,
);
}
await runTool(
getRuntime(),
manager.waitFor(ids, (pending) => {
onUpdate?.({
content: [
{ type: "text", text: `Waiting for ${pending.join(", ")}...` },
],
details: { pending },
});
}),
{ signal, interruptMessage: "Wait aborted. Subagents keep running." },
);
// Settlement may have happened before this wait began. Remove any
// deferred automatic delivery now that the tool is returning the result.
resultDelivery.consume(ids);
const sections: string[] = [];
let remainingBytes = WAIT_OUTPUT_MAX_BYTES;
for (const id of ids) {
const snap = manager.view.get(id);
if (!snap) {
sections.push(`## ${id}\n\n(no longer tracked)`);
continue;
}
const verb = snap.status === "error" ? "failed" : "finished";
let section = `## ${snap.id} "${snap.title}" ${verb}`;
if (snap.errorText) section += `\nError: ${snap.errorText}`;
const headerBytes = Buffer.byteLength(section, "utf8") + 2;
const outputBudget = Math.max(
512,
Math.min(WAIT_PER_AGENT_MAX_BYTES, remainingBytes - headerBytes),
);
section += `\n\n${truncatedOutput(snap, outputBudget)}`;
const sectionBytes = Buffer.byteLength(section, "utf8");
if (sectionBytes > remainingBytes) {
sections.push(
`## ${snap.id} "${snap.title}"\n\n[omitted: total wait output limit reached]`,
);
break;
}
sections.push(section);
remainingBytes -= sectionBytes;
}
const combined = sections.join("\n\n---\n\n");
const bounded = truncateHead(combined, {
maxBytes: WAIT_OUTPUT_MAX_BYTES - 128,
maxLines: DEFAULT_MAX_LINES,
});
const text = bounded.truncated
? `${bounded.content}\n\n[wait output truncated at the total output limit]`
: bounded.content;
return {
content: [{ type: "text", text }],
details: {
results: ids.map((id) => {
const snap = manager.view.get(id);
return { id, title: snap?.title, status: snap?.status };
}),
},
};
},
});
pi.registerTool({
name: "subagent_cancel",
label: "Cancel Subagents",
description: SUBAGENT_CANCEL_TOOL_DESCRIPTION,
parameters: Type.Object({
ids: Type.Array(Type.String(), {
description: SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS.ids,
}),
}),
async execute(_toolCallId, params, signal) {
const manager = await getManager();
const ids = [...new Set(params.ids)];
if (ids.length === 0)
throw new Error("Provide at least one subagent id.");
const known = manager.view
.list()
.filter(isModelVisible)
.map((snap) => snap.id);
const unknown = ids.filter((id) => {
const snap = manager.view.get(id);
return !snap || !isModelVisible(snap);
});
if (unknown.length > 0) {
throw new Error(
`Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`,
);
}
const report = await runTool(getRuntime(), manager.cancel(ids), {
signal,
interruptMessage: "Subagent cancellation aborted.",
});
const lines = report.map((entry) =>
entry.cancelled
? `Cancelled ${entry.id} "${entry.title}".`
: `${entry.id} "${entry.title}" was already ${entry.status}.`,
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
results: report.map((entry) => ({
id: entry.id,
title: entry.title,
status: entry.status,
})),
},
};
},
});
pi.registerTool({
name: "subagent_check",
label: "Check Subagent",
description: SUBAGENT_CHECK_TOOL_DESCRIPTION,
parameters: Type.Object({
id: Type.String({
description: SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS.id,
}),
}),
async execute(_toolCallId, params) {
const manager = await getManager();
const snap = manager.view.get(params.id);
if (!snap || !isModelVisible(snap)) {
const known = manager.view
.list()
.filter(isModelVisible)
.map((s) => s.id);
throw new Error(
`Unknown subagent id "${params.id}". Known: ${known.join(", ") || "none"}.`,
);
}
let text = `${describeSubagent(snap)}\nTurns: ${snap.turns}`;
if (snap.errorText) text += `\nError: ${snap.errorText}`;
const output = latestText(snap);
if (output) {
const preview = truncateHead(output, { maxBytes: 2048, maxLines: 20 });
text += `\n\nLatest output:\n${preview.content}`;
if (preview.truncated) text += "\n[...]";
} else if (snap.status === "running") {
text += "\n\n(no text output yet)";
}
return {
content: [{ type: "text", text }],
details: { id: snap.id, status: snap.status, turns: snap.turns },
};
},
});
pi.registerTool({
name: "subagent_list",
label: "List Subagents",
description: SUBAGENT_LIST_TOOL_DESCRIPTION,
parameters: Type.Object({}),
async execute() {
const manager = await getManager();
const subs = manager.view.list().filter(isModelVisible);
const text =
subs.length === 0
? "No subagents."
: subs.map((snap) => describeSubagent(snap)).join("\n");
return {
content: [{ type: "text", text }],
details: {
subagents: subs.map((snap) => ({
id: snap.id,
title: snap.title,
harness: snap.backend,
status: snap.status,
})),
},
};
},
});
// --- Result message rendering ------------------------------------------
pi.registerMessageRenderer(
"subagent-result",
(message, { expanded }, theme) => {
const details = (message.details ?? {}) as {
id?: string;
title?: string;
status?: string;
};
const failed = details.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
const header =
`${icon} ` +
theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
theme.fg(
"muted",
` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`,
);
const content =
typeof message.content === "string" ? message.content : "";
// Remove only the summary line. The following Error line (when present)
// is part of the actual result and must remain visible.
const body = content.split("\n").slice(1).join("\n").trim();
if (expanded) {
const md = new Markdown(`${body}`, 0, 0, getMarkdownTheme());
const container = new Text(header, 0, 0);
return {
render: (width: number) => [
...container.render(width),
...md.render(width),
],
invalidate: () => {
container.invalidate();
md.invalidate();
},
};
}
const previewLines = body.split("\n").slice(0, 8);
let text = header;
for (const line of previewLines)
text += `\n${theme.fg("toolOutput", line)}`;
if (body.split("\n").length > 8)
text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`;
return new Text(text, 0, 0);
},
);
pi.registerEntryRenderer<BtwResultData>(
"btw-result",
(entry, { expanded }, theme) => {
const data = entry.data;
const failed = data?.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
const header =
`${icon} ` +
theme.fg("accent", theme.bold(`by the way · ${data?.title ?? "?"}`)) +
theme.fg(
"muted",
` · ${failed ? "failed" : "answered"} · ${data?.id ?? "?"}`,
);
const body = [
data?.errorText ? `Error: ${data.errorText}` : "",
data?.answer ?? "(no answer)",
]
.filter(Boolean)
.join("\n\n");
if (expanded) {
const md = new Markdown(body, 0, 0, getMarkdownTheme());
const container = new Text(header, 0, 0);
return {
render: (width: number) => [
...container.render(width),
...md.render(width),
],
invalidate: () => {
container.invalidate();
md.invalidate();
},
};
}
const lines = body.split("\n");
let text = header;
for (const line of lines.slice(0, 8))
text += `\n${theme.fg("toolOutput", line)}`;
if (lines.length > 8)
text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`;
return new Text(text, 0, 0);
},
);
// --- Commands -----------------------------------------------------------
const runByTheWay = async (rawArgs: string, ctx: ExtensionCommandContext) => {
if (ctx.mode !== "tui") {
if (ctx.hasUI)
ctx.ui.notify("by the way is only available in the TUI", "error");
return;
}
let prompt = rawArgs.trim();
if (!prompt) {
const input = await ctx.ui.input("by the way", "Ask a one-off question…");
prompt = input?.trim() ?? "";
if (!prompt) return;
}
const manager = await getManager();
let snap: SubagentSnapshot;
try {
snap = await runTool(
getRuntime(),
manager.spawn("pi", {
origin: "btw",
prompt,
title: deriveBtwTitle(prompt),
cwd: ctx.cwd,
parent: {
parentCwd: ctx.cwd,
projectTrusted: ctx.isProjectTrusted(),
inheritedModel: ctx.model
? { provider: ctx.model.provider, id: ctx.model.id }
: undefined,
inheritedThinkingLevel: pi.getThinkingLevel(),
modelRegistry: ctx.modelRegistry,
},
}),
);
} catch (error) {
ctx.ui.notify(
error instanceof Error ? error.message : String(error),
"error",
);
return;
}
await openSubagentTakeover(ctx, manager.view, snap.id, {
badge: "by the way",
});
};
pi.registerCommand("btw", {
description:
"Ask a one-off side question while the main agent keeps working",
handler: runByTheWay,
});
pi.registerCommand("subagents", {
description: "List, inspect, and take over subagents",
handler: async (_args, ctx) => {
if (ctx.mode !== "tui") {
if (ctx.hasUI)
ctx.ui.notify(
"Subagent takeover is only available in the TUI",
"error",
);
return;
}
const manager = await getManager();
if (manager.view.size() === 0) {
ctx.ui.notify(
"No subagents yet. The agent spawns them with subagent_spawn.",
"info",
);
return;
}
await openSubagentPicker(ctx, manager.view);
},
});
}
+276
View File
@@ -0,0 +1,276 @@
/**
* End-to-end smoke tests: manager behavior through a real ManagedRuntime,
* exactly as the tool handlers drive it. The registry is test-only: scripted
* stub sessions registered under the claude/codex names (the production
* backends launch real processes and have their own live test files), plus
* the real pi backend for its cheap registry precondition.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { Effect, Layer, ManagedRuntime } from "effect";
import { BackendRegistry, type SubagentBackend } from "./src/backend.ts";
import { piBackend } from "./src/backends/pi.ts";
import { makeStubBackend } from "./src/backends/stub.ts";
import type { BackendName, ParentContext, SpawnTask } from "./src/domain.ts";
import {
SubagentManager,
SubagentManagerLive,
type SubagentManagerShape,
} from "./src/manager.ts";
import { runTool } from "./src/runtime.ts";
const TestRegistryLive = Layer.sync(BackendRegistry, () => {
const backends: SubagentBackend[] = [
piBackend,
makeStubBackend({
backend: "claude",
defaultModelLabel: "claude/sonnet",
contextWindow: 200_000,
toolName: "Bash",
cadenceMs: 40,
}),
makeStubBackend({
backend: "codex",
defaultModelLabel: "codex/gpt-5-codex",
contextWindow: 272_000,
toolName: "shell",
cadenceMs: 30,
}),
];
return new Map<BackendName, SubagentBackend>(
backends.map((backend) => [backend.name, backend]),
);
});
const createTestRuntime = () =>
ManagedRuntime.make(
SubagentManagerLive.pipe(Layer.provide(TestRegistryLive)),
);
const parent: ParentContext = {
parentCwd: process.cwd(),
projectTrusted: false,
};
function task(prompt: string): SpawnTask {
return { prompt, title: "test", cwd: process.cwd(), parent };
}
async function withManager(
run: (
manager: SubagentManagerShape,
runtime: ReturnType<typeof createTestRuntime>,
) => Promise<void>,
) {
const runtime = createTestRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
await run(manager, runtime);
} finally {
await runtime.dispose();
}
}
test("stub subagent completes and delivers a final result", async () => {
await withManager(async (manager, runtime) => {
const settled: Array<{ id: string; consumed: boolean }> = [];
manager.view.setOnSettled((snap, consumed) =>
settled.push({ id: snap.id, consumed }),
);
const snap = await runTool(
runtime,
manager.spawn("claude", task("Say hello to the tests")),
);
assert.equal(snap.status, "running");
assert.equal(snap.backend, "claude");
assert.ok(snap.meta.sessionFilePath);
await runTool(runtime, manager.waitFor([snap.id]));
const done = manager.view.get(snap.id);
assert.ok(done);
assert.equal(done.status, "done");
assert.match(
done.finalText,
/\[stub:claude\] completed: Say hello to the tests/,
);
assert.ok(done.turns >= 2);
assert.ok(done.transcript.some((item) => item.kind === "toolResult"));
// The waitFor marked the settle as consumed.
assert.deepEqual(settled, [{ id: snap.id, consumed: true }]);
});
});
test("FAIL: prompts settle as errors; unconsumed settles are delivered", async () => {
await withManager(async (manager, runtime) => {
const settled: Array<{ id: string; consumed: boolean }> = [];
manager.view.setOnSettled((snap, consumed) =>
settled.push({ id: snap.id, consumed }),
);
const snap = await runTool(
runtime,
manager.spawn("codex", task("FAIL: blow up please")),
);
// Poll without wait-interest so the settle is delivered unconsumed.
while (manager.view.get(snap.id)?.status === "running") {
await new Promise((resolve) => setTimeout(resolve, 50));
}
const failed = manager.view.get(snap.id);
assert.equal(failed?.status, "error");
assert.match(failed?.errorText ?? "", /task failed/);
assert.deepEqual(settled, [{ id: snap.id, consumed: false }]);
});
});
test("cancel interrupts a running stub subagent", async () => {
await withManager(async (manager, runtime) => {
const snap = await runTool(
runtime,
manager.spawn("claude", task("Long running task")),
);
const report = await runTool(runtime, manager.cancel([snap.id]));
assert.deepEqual(report, [
{ id: snap.id, title: "test", status: "error", cancelled: true },
]);
assert.equal(manager.view.get(snap.id)?.errorText, "Run was aborted");
});
});
test("spawn origin propagates to ids, snapshots, and settlement", async () => {
await withManager(async (manager, runtime) => {
const settled: Array<{ id: string; origin: string }> = [];
manager.view.setOnSettled((snap) =>
settled.push({ id: snap.id, origin: snap.origin }),
);
const model = await runTool(
runtime,
manager.spawn("codex", task("model task")),
);
const btw = await runTool(
runtime,
manager.spawn("claude", { ...task("side question"), origin: "btw" }),
);
assert.match(model.id, /^sa-/);
assert.equal(model.origin, "model");
assert.match(btw.id, /^btw-/);
assert.equal(btw.origin, "btw");
await runTool(runtime, manager.cancel([model.id, btw.id]));
assert.deepEqual(
settled.sort((a, b) => a.id.localeCompare(b.id)),
[
{ id: btw.id, origin: "btw" },
{ id: model.id, origin: "model" },
].sort((a, b) => a.id.localeCompare(b.id)),
);
});
});
test("the global concurrency cap includes by-the-way sessions", async () => {
await withManager(async (manager, runtime) => {
const tasks: SpawnTask[] = [
{ ...task("side question"), origin: "btw" },
task("Task 2"),
task("Task 3"),
task("Task 4"),
];
const spawns = await runTool(
runtime,
Effect.forEach(tasks, (spawnTask) => manager.spawn("codex", spawnTask), {
concurrency: "unbounded",
}),
);
assert.equal(spawns.length, 4);
await assert.rejects(
runTool(
runtime,
manager.spawn("codex", {
...task("another side question"),
origin: "btw",
}),
),
/Max 4 subagents/,
);
});
});
test("the concurrency cap rejects a fifth running subagent", async () => {
await withManager(async (manager, runtime) => {
const spawns = await runTool(
runtime,
Effect.forEach(
[1, 2, 3, 4],
(n) => manager.spawn("codex", task(`Task ${n}`)),
{ concurrency: "unbounded" },
),
);
assert.equal(spawns.length, 4);
await assert.rejects(
runTool(runtime, manager.spawn("codex", task("Task 5"))),
/Max 4 subagents/,
);
});
});
test("pi spawn fails fast without the parent model registry", async () => {
await withManager(async (manager, runtime) => {
await assert.rejects(
runTool(runtime, manager.spawn("pi", task("needs a registry"))),
/model registry/,
);
// The failed spawn must release its concurrency reservation.
const snap = await runTool(runtime, manager.spawn("codex", task("ok")));
assert.equal(snap.backend, "codex");
});
});
test("idle restarts respect the concurrency cap", async () => {
await withManager(async (manager, runtime) => {
// Settle one subagent, then fill all four slots with running ones.
const settled = await runTool(
runtime,
manager.spawn("claude", task("early finisher")),
);
await runTool(runtime, manager.waitFor([settled.id]));
await runTool(
runtime,
Effect.forEach(
[1, 2, 3, 4],
(n) => manager.spawn("codex", task(`Task ${n}`)),
{ concurrency: "unbounded" },
),
);
// Restarting the settled one would be a fifth concurrent run.
await assert.rejects(
runTool(runtime, manager.send(settled.id, "go again")),
/Max 4 subagents/,
);
assert.equal(manager.view.get(settled.id)?.status, "done");
});
});
test("send steers an idle subagent into another turn", async () => {
await withManager(async (manager, runtime) => {
const snap = await runTool(
runtime,
manager.spawn("claude", task("First turn")),
);
await runTool(runtime, manager.waitFor([snap.id]));
const afterFirst = manager.view.get(snap.id);
assert.equal(afterFirst?.status, "done");
await runTool(runtime, manager.send(snap.id, "Second turn"));
// The fresh run flips the status back to running...
while (manager.view.get(snap.id)?.status !== "running") {
await new Promise((resolve) => setTimeout(resolve, 10));
}
await runTool(runtime, manager.waitFor([snap.id]));
const afterSecond = manager.view.get(snap.id);
assert.equal(afterSecond?.status, "done");
assert.match(afterSecond?.finalText ?? "", /Second turn/);
});
});
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDeferredResultDelivery } from "./src/result-delivery.ts";
test("a result consumed by a later wait is not delivered", () => {
const delivery = createDeferredResultDelivery<{
id: string;
output: string;
}>();
delivery.defer({ id: "sa-1", output: "done" });
delivery.consume(["sa-1"]);
assert.deepEqual(delivery.drain(), []);
});
test("unconsumed results are delivered once in settlement order", () => {
const delivery = createDeferredResultDelivery<{ id: string }>();
const first = { id: "sa-1" };
const second = { id: "sa-2" };
delivery.defer(first);
delivery.defer(second);
assert.deepEqual(delivery.drain(), [first, second]);
assert.deepEqual(delivery.drain(), []);
});
+73
View File
@@ -0,0 +1,73 @@
/**
* The unified backend interface: one `SubagentBackend` per agent runtime
* (pi, Claude Code, Codex), all producing the same `SubagentSession` shape.
*
* Planned real implementations (currently stubbed in ./backends/):
* - pi: in-process `createAgentSession()` via the pi SDK.
* - claude: `@anthropic-ai/claude-agent-sdk` `query()` in streaming-input mode.
* - codex: `codex app-server` child process speaking JSON-RPC over stdio.
*/
import type { Effect, Scope, Stream } from "effect";
import { Context } from "effect";
import type {
BackendName,
SendError,
SpawnError,
SpawnTask,
SubagentEvent,
SubagentMeta,
} from "./domain.ts";
export interface BackendCapabilities {
/** Can send() steer a live run (vs. only starting a fresh run when idle). */
readonly steering: boolean;
readonly modelSelection: boolean;
readonly reasoningEffort: boolean;
}
/**
* A live subagent session. The manager is the single consumer of `events`;
* it folds them into the `SubagentSnapshot` everything else reads.
*/
export interface SubagentSession {
/** Current metadata snapshot. Updates also arrive as MetaChanged events. */
readonly meta: Effect.Effect<SubagentMeta>;
/**
* All activity, normalized. Ends when the session's scope closes. Every
* run started within the session terminates with a RunSettled event.
*/
readonly events: Stream.Stream<SubagentEvent>;
/**
* Steer the active run, or start a fresh run when idle (v1 `manager.send`
* semantics — the "is a run active" decision is backend-native state).
*/
send(text: string): Effect.Effect<void, SendError>;
/**
* Interrupt the active run. Resolves once the backend acknowledges; the
* corresponding RunSettled(Interrupted) arrives on `events`. Callers bound
* this with a timeout and fall back to closing the session scope.
*/
readonly interrupt: Effect.Effect<void>;
}
export interface SubagentBackend {
readonly name: BackendName;
readonly capabilities: BackendCapabilities;
/** Probe availability (binary on PATH, SDK importable, credentials). */
readonly available: Effect.Effect<boolean>;
/**
* Spawn a session. Scoped: closing the scope interrupts/kills the
* underlying session or process and ends `events`. Fire-and-forget
* semantics (background fibers, result delivery) live in the manager.
*/
spawn(
task: SpawnTask,
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope>;
}
/** Registry of all wired backends, keyed by name. */
export class BackendRegistry extends Context.Service<
BackendRegistry,
ReadonlyMap<BackendName, SubagentBackend>
>()("subagents/BackendRegistry") {}
+701
View File
@@ -0,0 +1,701 @@
/**
* Claude Code backend — real implementation over the Claude Agent SDK.
*
* One SDK `query()` and one streaming-input bridge live for the whole scoped
* session. User sends are pushed into that bridge, while the query iterator is
* pumped in the background and translated to normalized SubagentEvents. The
* CLI therefore owns conversation continuity, tool execution, and persisted
* `~/.claude/projects` transcripts; this file only owns lifecycle guarantees
* and the normalized view consumed by the manager.
*/
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
query,
type SDKAssistantMessage,
type SDKMessage,
type SDKResultMessage,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { Cause, Scope } from "effect";
import { Effect, Queue, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
QueuedMessage,
ReasoningEffort,
RunOutcome,
SpawnTask,
SubagentEvent,
SubagentMeta,
TranscriptPart,
} from "../domain.ts";
import { SendError, SpawnError } from "../domain.ts";
const CLAUDE_CONTEXT_WINDOW = 200_000;
const INTERRUPT_TIMEOUT_MS = 2_000;
const PREVIEW_MAX_LENGTH = 4_096;
// --- Binary resolution --------------------------------------------------------
let cachedClaudeBinary: string | null | undefined;
function executable(file: string) {
try {
fs.accessSync(file, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Resolve the Claude Code CLI once from PATH. The SDK can also run without
* this (it bundles a CLI), but pointing it at the user's installed binary
* keeps versions, settings, and login state consistent with their terminal.
*/
function resolveClaudeBinary() {
if (cachedClaudeBinary !== undefined) return cachedClaudeBinary ?? undefined;
const names =
process.platform === "win32"
? ["claude.exe", "claude.cmd", "claude"]
: ["claude"];
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
if (!directory) continue;
for (const name of names) {
const candidate = path.join(directory, name);
if (executable(candidate)) {
cachedClaudeBinary = candidate;
return candidate;
}
}
}
cachedClaudeBinary = null;
return undefined;
}
// --- Streaming input ---------------------------------------------------------
/** A single-consumer push queue adapted to the SDK's AsyncIterable input. */
class ClaudeInput implements AsyncIterable<SDKUserMessage> {
private pending: SDKUserMessage[] = [];
private waiter:
((result: IteratorResult<SDKUserMessage>) => void) | undefined;
private closed = false;
push(text: string) {
if (this.closed) return undefined;
const message: SDKUserMessage = {
type: "user",
message: { role: "user", content: text },
parent_tool_use_id: null,
// Stamped UUIDs make queued messages visible in interrupt receipts.
uuid: randomUUID(),
};
const waiter = this.waiter;
if (waiter) {
this.waiter = undefined;
waiter({ value: message, done: false });
} else {
this.pending.push(message);
}
return message;
}
clear() {
return this.pending.splice(0);
}
end() {
if (this.closed) return;
this.closed = true;
const waiter = this.waiter;
this.waiter = undefined;
waiter?.({ value: undefined, done: true });
}
async *[Symbol.asyncIterator]() {
while (true) {
const message = this.pending.shift();
if (message) {
yield message;
continue;
}
if (this.closed) return;
const next = await new Promise<IteratorResult<SDKUserMessage>>(
(resolve) => {
this.waiter = resolve;
},
);
if (next.done) return;
yield next.value;
}
}
}
// --- Model, effort, and transcript helpers ----------------------------------
/**
* Claude's deprecated-but-supported maxThinkingTokens is the closest match to
* the shared numeric scale requested by this extension. Zero explicitly
* disables extended thinking in SDK 0.3.207; an omitted effort leaves the CLI
* default untouched.
*/
const THINKING_BUDGETS = {
off: 0,
minimal: 1_024,
low: 4_096,
medium: 10_000,
high: 16_000,
xhigh: 32_000,
max: 63_999,
} satisfies Record<ReasoningEffort, number>;
function boundedError(error: unknown) {
return (error instanceof Error ? error.message : String(error)).slice(
0,
4_096,
);
}
function singleLine(text: string) {
const flattened = text.replace(/\s+/g, " ").trim();
return flattened ? flattened.slice(0, PREVIEW_MAX_LENGTH) : undefined;
}
function safeJson(value: unknown) {
try {
const text = JSON.stringify(value);
if (!text || text === "{}") return undefined;
return singleLine(text);
} catch {
return undefined;
}
}
function outputPreview(value: unknown): string | undefined {
if (typeof value === "string") return singleLine(value);
if (Array.isArray(value)) {
const text = value
.flatMap((part) => {
if (!part || typeof part !== "object") return [];
const record = part as { type?: unknown; text?: unknown };
return record.type === "text" && typeof record.text === "string"
? [record.text]
: [];
})
.join(" ");
return singleLine(text) ?? safeJson(value);
}
return safeJson(value);
}
function assistantParts(message: SDKAssistantMessage): TranscriptPart[] {
const parts: TranscriptPart[] = [];
for (const block of message.message.content) {
if (block.type === "text") {
parts.push({ type: "text", text: block.text });
} else if (block.type === "thinking") {
parts.push({ type: "thinking", text: block.thinking });
} else if (block.type === "redacted_thinking") {
parts.push({ type: "thinking", text: "", redacted: true });
} else if (block.type === "tool_use") {
parts.push({
type: "toolCall",
toolId: block.id,
name: block.name,
argsPreview: safeJson(block.input),
});
}
}
return parts;
}
/** Claude Code's project-directory escaping, verified against CLI 2.1.207. */
function sessionFilePath(cwd: string, sessionId: string) {
const projectDirectory = cwd.replace(/[/.]/g, "-");
return path.join(
os.homedir(),
".claude",
"projects",
projectDirectory,
`${sessionId}.jsonl`,
);
}
/**
* Context occupancy after one API request. An assistant message's `usage`
* describes only that request: the full prompt (fresh + cache-read +
* cache-written input) plus this response's output — exactly what now sits
* in the context window. The result message's `usage` instead sums these
* per-request counts across the whole run, so a multi-request turn
* re-counts cached context once per request and quickly exceeds the real
* window; it must never be treated as occupancy.
*/
export function contextOccupancyTokens(
usage:
| {
input_tokens?: number | null;
cache_read_input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
output_tokens?: number | null;
}
| null
| undefined,
) {
if (!usage || typeof usage.input_tokens !== "number") return undefined;
const count = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
return (
count(usage.input_tokens) +
count(usage.cache_read_input_tokens) +
count(usage.cache_creation_input_tokens) +
count(usage.output_tokens)
);
}
function resultContextWindow(result: SDKResultMessage) {
return Object.values(result.modelUsage)[0]?.contextWindow;
}
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
return Promise.race([
operation.then(
() => undefined,
() => undefined,
),
timeout,
]).finally(() => {
if (timer) clearTimeout(timer);
});
}
// --- The session -------------------------------------------------------------
interface NativeQueuedMessage extends QueuedMessage {
readonly uuid: string;
/** Raw response sequence after which this steer can be consumed. */
readonly afterResponse: number;
}
const makeClaudeSession = (
task: SpawnTask,
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
Effect.gen(function* () {
const input = new ClaudeInput();
const abortController = new AbortController();
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const emit = (event: SubagentEvent) => {
Queue.offerUnsafe(events, event);
};
const state = {
closed: false,
activeRun: false,
interruptRequested: false,
runVersion: 0,
lastSettledVersion: 0,
responseSequence: 0,
queued: [] as NativeQueuedMessage[],
submittedUuids: new Set<string>(),
currentText: "",
liveText: "",
tools: new Map<string, string>(),
settleWaiters: new Set<() => void>(),
meta: {
backend: "claude",
modelLabel: task.model,
// Claude models used by this backend currently expose 200k context;
// result.modelUsage replaces this fallback when the CLI knows better.
contextWindow: CLAUDE_CONTEXT_WINDOW,
} satisfies SubagentMeta as SubagentMeta,
};
const thinkingBudget = task.reasoningEffort
? THINKING_BUDGETS[task.reasoningEffort]
: undefined;
const claudeBinary = resolveClaudeBinary();
const nativeQuery = yield* Effect.try({
try: () =>
query({
prompt: input,
options: {
cwd: task.cwd,
// Headless children cannot answer approval prompts. The caller
// already chose to launch an autonomous subagent, so let it use
// its tools without interactive permission checks.
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
// Keep child orchestration inside this extension's global manager
// and concurrency cap rather than Claude Code's native subagents.
disallowedTools: ["Agent", "Task"],
// For cwds pi marked untrusted, restrict to user-level settings so
// an untrusted project's config cannot reconfigure the child.
...(task.parent.projectTrusted
? {}
: { settingSources: ["user" as const] }),
includePartialMessages: true,
abortController,
...(claudeBinary
? { pathToClaudeCodeExecutable: claudeBinary }
: {}),
...(task.model ? { model: task.model } : {}),
...(thinkingBudget !== undefined
? { maxThinkingTokens: thinkingBudget }
: {}),
},
}),
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
let resolvePumpDone: (() => void) | undefined;
const pumpDone = new Promise<void>((resolve) => {
resolvePumpDone = resolve;
});
const queuedView = (): ReadonlyArray<QueuedMessage> =>
state.queued.map(({ text, kind }) => ({ text, kind }));
const notifySettled = () => {
for (const waiter of state.settleWaiters) waiter();
state.settleWaiters.clear();
};
const partialText = () => state.liveText || state.currentText || undefined;
const settle = (outcome: RunOutcome) => {
if (!state.activeRun) return;
emit({ _tag: "RunSettled", outcome });
state.activeRun = false;
state.lastSettledVersion = state.runVersion;
state.interruptRequested = false;
state.liveText = "";
notifySettled();
};
const updateMeta = (patch: Partial<SubagentMeta>) => {
state.meta = { ...state.meta, ...patch };
emit({ _tag: "MetaChanged", meta: patch });
};
const beginQueuedRunIfNeeded = () => {
if (state.activeRun || state.queued.length === 0) return;
// A steer arriving too late for the prior turn becomes a fresh queued
// turn. The repeated init is the first reliable signal that it started.
state.activeRun = true;
state.runVersion++;
state.currentText = "";
state.liveText = "";
emit({ _tag: "RunStarted" });
};
const clearConsumedSteers = () => {
const remaining = state.queued.filter(
(message) => message.afterResponse >= state.responseSequence,
);
if (remaining.length === state.queued.length) return;
state.queued = remaining;
emit({ _tag: "QueueChanged", queued: queuedView() });
};
const handleAssistant = (message: SDKAssistantMessage) => {
const parts = assistantParts(message);
if (parts.length > 0) emit({ _tag: "AssistantMessage", parts });
// Top-level messages only: subagent (sidechain) requests have their own
// context and must not overwrite this conversation's occupancy.
if (message.parent_tool_use_id == null) {
const tokens = contextOccupancyTokens(message.message.usage);
if (tokens !== undefined) emit({ _tag: "UsageChanged", tokens });
}
const text = message.message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
if (text) state.currentText = text;
state.liveText = "";
if (message.message.model !== state.meta.modelLabel) {
updateMeta({ modelLabel: message.message.model });
}
for (const block of message.message.content) {
if (block.type !== "tool_use") continue;
state.tools.set(block.id, block.name);
emit({
_tag: "ToolStart",
toolId: block.id,
name: block.name,
argsPreview: safeJson(block.input),
});
}
};
const handleUser = (message: SDKUserMessage) => {
const content = message.message.content;
if (!Array.isArray(content)) return;
for (const block of content) {
if (block.type !== "tool_result") continue;
const name = state.tools.get(block.tool_use_id) ?? "Tool";
state.tools.delete(block.tool_use_id);
emit({
_tag: "ToolEnd",
toolId: block.tool_use_id,
name,
isError: block.is_error ?? false,
outputPreview: outputPreview(block.content),
});
}
// External prompts are emitted synchronously by submit(); ignoring text
// here prevents a future CLI echo from duplicating the transcript row.
};
const handleResult = (result: SDKResultMessage) => {
// result.usage is a whole-run aggregate, not occupancy (see
// contextOccupancyTokens); only the capacity is trustworthy here. The
// occupancy itself was already emitted by the last assistant message.
const contextWindow = resultContextWindow(result);
emit({
_tag: "UsageChanged",
contextWindow: contextWindow ?? state.meta.contextWindow,
});
if (
contextWindow !== undefined &&
contextWindow !== state.meta.contextWindow
) {
updateMeta({ contextWindow });
}
if (state.interruptRequested) {
settle({ _tag: "Interrupted", partialText: partialText() });
} else if (result.subtype === "success") {
settle({
_tag: "Completed",
finalText: result.result.trim() || state.currentText,
});
} else {
const details =
result.errors.filter((error) => error.trim()).join("\n") ||
result.stop_reason ||
`Claude Code ended with ${result.subtype}`;
settle({
_tag: "Failed",
errorText: boundedError(details),
partialText: partialText(),
});
}
};
const handleMessage = (message: SDKMessage) => {
if (state.closed) return;
// A queued steer's turn normally announces itself with a repeated
// system/init, but any turn activity is an equally valid begin signal —
// without this, a missed init would stream events into a "done" run
// that could then never settle.
if (
message.type === "stream_event" ||
message.type === "assistant" ||
message.type === "result"
) {
beginQueuedRunIfNeeded();
}
if (message.type === "system" && message.subtype === "init") {
beginQueuedRunIfNeeded();
updateMeta({
modelLabel: message.model,
nativeSessionId: message.session_id,
sessionFilePath: sessionFilePath(message.cwd, message.session_id),
contextWindow: CLAUDE_CONTEXT_WINDOW,
});
} else if (message.type === "stream_event") {
if (message.parent_tool_use_id !== null) return;
if (message.event.type === "message_start") {
state.responseSequence++;
clearConsumedSteers();
} else if (message.event.type === "content_block_delta") {
const delta = message.event.delta;
if (delta.type === "text_delta") {
state.liveText += delta.text;
emit({ _tag: "AssistantDelta", kind: "text", delta: delta.text });
} else if (delta.type === "thinking_delta") {
emit({
_tag: "AssistantDelta",
kind: "thinking",
delta: delta.thinking,
});
}
}
} else if (message.type === "assistant") {
handleAssistant(message);
} else if (message.type === "user") {
handleUser(message);
} else if (message.type === "result") {
handleResult(message);
}
};
const pump = async () => {
let failure: string | undefined;
try {
for await (const message of nativeQuery) handleMessage(message);
} catch (error) {
if (!state.closed && !abortController.signal.aborted) {
failure = boundedError(error);
}
} finally {
if (!state.closed) {
if (state.activeRun) {
settle(
state.interruptRequested
? { _tag: "Interrupted", partialText: partialText() }
: {
_tag: "Failed",
errorText:
failure ?? "Claude Code query ended unexpectedly",
partialText: partialText(),
},
);
} else if (failure) {
emit({ _tag: "BackendError", message: failure });
}
state.closed = true;
Queue.endUnsafe(events);
}
resolvePumpDone?.();
}
};
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
// Settle before marking closed: the pump's finally skips settlement
// once closed, and every run must end in a RunSettled even when the
// scope closes mid-run.
if (state.activeRun) {
settle({ _tag: "Interrupted", partialText: partialText() });
}
state.closed = true;
input.end();
abortController.abort();
nativeQuery.close();
await waitBounded(pumpDone, INTERRUPT_TIMEOUT_MS);
Queue.endUnsafe(events);
}),
);
void pump();
const submit = (text: string) => {
const wasActive = state.activeRun;
const message = input.push(text);
if (!message) return false;
if (!wasActive) {
state.activeRun = true;
state.runVersion++;
state.currentText = "";
state.liveText = "";
}
state.submittedUuids.add(message.uuid ?? "");
// Idle restarts flip status synchronously (like pi's startRun); a steer
// into an active run must NOT re-emit RunStarted — its own turn begins
// later via beginQueuedRunIfNeeded.
if (!wasActive) emit({ _tag: "RunStarted" });
emit({ _tag: "UserMessage", text });
if (wasActive) {
state.queued.push({
text,
kind: "steer",
uuid: message.uuid ?? "",
afterResponse: state.responseSequence,
});
emit({ _tag: "QueueChanged", queued: queuedView() });
}
return true;
};
const waitForVersion = (version: number) => {
if (state.lastSettledVersion >= version) return Promise.resolve();
return new Promise<void>((resolve) => {
const waiter = () => {
if (state.lastSettledVersion < version && !state.closed) return;
state.settleWaiters.delete(waiter);
resolve();
};
state.settleWaiters.add(waiter);
});
};
emit({ _tag: "MetaChanged", meta: state.meta });
submit(task.prompt);
return {
meta: Effect.sync(() => state.meta),
events: Stream.fromQueue(events),
send: (text) =>
Effect.suspend((): Effect.Effect<void, SendError> => {
if (state.closed) {
return new SendError({ message: "Subagent session is closed." });
}
return submit(text)
? Effect.void
: new SendError({ message: "Subagent session is closed." });
}),
interrupt: Effect.promise(async () => {
if (state.closed || !state.activeRun) return;
const version = state.runVersion;
state.interruptRequested = true;
input.clear();
state.queued = [];
emit({ _tag: "QueueChanged", queued: [] });
const interruptAndSettle = (async () => {
try {
const receipt = await nativeQuery.interrupt();
const hasOwnQueuedMessage = receipt?.still_queued?.some((uuid) =>
state.submittedUuids.has(uuid),
);
if (hasOwnQueuedMessage) {
// 0.3.207 exposes cancellation receipts but no public per-message
// cancel method. Closing is the only way to prevent a cancelled
// queued prompt from immediately starting another turn.
input.end();
nativeQuery.close();
}
} catch (error) {
if (!state.closed) {
emit({ _tag: "BackendError", message: boundedError(error) });
}
}
await waitForVersion(version);
})();
await waitBounded(interruptAndSettle, INTERRUPT_TIMEOUT_MS);
if (!state.closed && state.activeRun && state.runVersion === version) {
// Covers pre-init/pending races and SDK versions that acknowledge an
// interrupt without delivering a result. Force-close after settling
// so a late native result cannot resurrect or re-settle this run.
settle({ _tag: "Interrupted", partialText: partialText() });
state.closed = true;
input.end();
abortController.abort();
nativeQuery.close();
Queue.endUnsafe(events);
}
}),
} satisfies SubagentSession;
});
// --- Backend -----------------------------------------------------------------
export const claudeBackend: SubagentBackend = {
name: "claude",
capabilities: { steering: true, modelSelection: true, reasoningEffort: true },
available: Effect.sync(() => resolveClaudeBinary() !== undefined),
spawn: makeClaudeSession,
};
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
/**
* pi backend — real implementation over the pi SDK.
*
* Each subagent is an in-process `AgentSession` (a port of v1
* subagents/manager.ts + shared/child-session.ts):
* - real session files visible in /resume, child resources loaded per-cwd
* with trust gating, and the child tool denylist;
* - `session.subscribe()` events translated to normalized SubagentEvents;
* - send() steers a streaming run or starts a fresh prompt() when idle;
* - interrupt clears the queue and aborts; closing the session scope emits
* the child session_shutdown hook and disposes the session.
*/
import type { AssistantMessage, Message, Model } from "@earendil-works/pi-ai";
import type {
AgentSession,
AgentSessionEvent,
ModelRegistry,
} from "@earendil-works/pi-coding-agent";
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import type { Cause, Scope } from "effect";
import { Effect, Queue, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
SpawnTask,
SubagentEvent,
SubagentMeta,
TranscriptPart,
} from "../domain.ts";
import { SendError, SpawnError } from "../domain.ts";
import { createToolCallTimeoutGuard } from "../../../shared/tool-call-timeout.ts";
const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
/** Tools that headless children must not receive. Everything else stays enabled. */
const CHILD_EXCLUDED_TOOL_NAMES = [
"subagent_spawn",
"subagent_wait",
"subagent_cancel",
"subagent_check",
"subagent_list",
"workflow",
"ask_user",
] as const;
// --- Model + effort resolution -----------------------------------------------
type ThinkingLevel = NonNullable<
NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]
>;
/**
* Resolve the generic model hint against the parent registry (v1 semantics):
* "provider/model-id" is exact; a bare id prefers the inherited provider,
* then must be unambiguous across providers. No hint inherits the parent
* model; with nothing to inherit, the SDK default applies.
*/
function resolvePiModel(
registry: ModelRegistry,
hint: string | undefined,
inherited: { provider: string; id: string } | undefined,
): Model<any> | undefined {
if (!hint) {
if (!inherited) return undefined;
return registry.find(inherited.provider, inherited.id) ?? undefined;
}
const slash = hint.indexOf("/");
if (slash > 0) {
const provider = hint.slice(0, slash);
const id = hint.slice(slash + 1);
const found = registry.find(provider, id);
if (found) return found;
throw new Error(`Unknown model "${hint}".`);
}
if (inherited) {
const found = registry.find(inherited.provider, hint);
if (found) return found;
}
const matches = registry.getAll().filter((m) => m.id === hint);
if (matches.length === 1) return matches[0];
if (matches.length > 1) {
throw new Error(
`Model "${hint}" exists in multiple providers (${matches.map((m) => m.provider).join(", ")}). Use "provider/${hint}".`,
);
}
throw new Error(`Unknown model "${hint}".`);
}
// --- Child session helpers (ported from v1 shared/child-session.ts) -----------
/** Load normal global/package resources and trust-gated project resources. */
async function createChildResources(cwd: string, projectTrusted: boolean) {
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir, {
projectTrusted,
});
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await loader.reload();
return { loader, settingsManager };
}
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
return Promise.race([
operation.then(
() => undefined,
() => undefined,
),
timeout,
])
.catch(() => {})
.finally(() => {
if (timer) clearTimeout(timer);
});
}
/** Emit child session_shutdown (bounded), then dispose. Never throws. */
async function shutdownAndDisposeChildSession(session: AgentSession) {
try {
if (session.extensionRunner.hasHandlers("session_shutdown")) {
await waitBounded(
session.extensionRunner.emit({
type: "session_shutdown",
reason: "quit",
}),
CHILD_SHUTDOWN_TIMEOUT_MS,
);
}
} catch {
// Extension runner inspection/emission is best-effort during teardown.
} finally {
try {
session.dispose();
} catch {
// Disposal is terminal and must remain idempotent for callers.
}
}
}
// --- Event translation ----------------------------------------------------------
function messageRole(msg: unknown): Message["role"] | undefined {
const role = (msg as { role?: string } | undefined)?.role;
if (role === "user" || role === "assistant" || role === "toolResult")
return role;
return undefined;
}
function lastAssistantMessage(
session: AgentSession,
): AssistantMessage | undefined {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (messageRole(msg) === "assistant") return msg as AssistantMessage;
}
return undefined;
}
/** Final assistant text output (last assistant message with text), v1 semantics. */
function finalOutput(session: AgentSession): string {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (messageRole(msg) !== "assistant") continue;
const text = (msg as AssistantMessage).content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (text) return text;
}
return "";
}
function safeJson(value: unknown): string | undefined {
try {
const text = JSON.stringify(value);
return text === "{}" ? undefined : text.slice(0, 4_096);
} catch {
return undefined;
}
}
/** First non-empty line of a tool result-ish value (v1 liveToolPreview). */
function toolPreview(value: unknown): string | undefined {
if (typeof value === "string") {
return value
.split("\n")
.find((line) => line.trim())
?.trim();
}
if (!value || typeof value !== "object") return undefined;
const content = (value as { content?: unknown }).content;
if (!Array.isArray(content)) return undefined;
for (const part of content) {
if (!part || typeof part !== "object") continue;
const record = part as { type?: unknown; text?: unknown };
if (record.type !== "text" || typeof record.text !== "string") continue;
const firstLine = record.text.split("\n").find((line) => line.trim());
if (firstLine) return firstLine.trim();
}
return undefined;
}
function assistantParts(msg: AssistantMessage): TranscriptPart[] {
const parts: TranscriptPart[] = [];
for (const part of msg.content) {
if (part.type === "text") {
parts.push({ type: "text", text: part.text });
} else if (part.type === "thinking") {
parts.push({
type: "thinking",
text: part.redacted ? "" : part.thinking,
redacted: part.redacted,
});
} else if (part.type === "toolCall") {
parts.push({
type: "toolCall",
toolId: part.id,
name: part.name,
argsPreview: safeJson(part.arguments),
});
}
}
return parts;
}
function userText(msg: Message): string {
const content = (msg as { content: unknown }).content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(part): part is { type: "text"; text: string } =>
!!part &&
typeof part === "object" &&
(part as { type?: unknown }).type === "text",
)
.map((part) => part.text)
.join("\n");
}
// --- The session ------------------------------------------------------------------
function boundedError(error: unknown) {
return (error instanceof Error ? error.message : String(error)).slice(
0,
4096,
);
}
const makePiSession = (
task: SpawnTask,
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
Effect.gen(function* () {
const registry = task.parent.modelRegistry;
if (!registry) {
return yield* new SpawnError({
message: "pi backend requires the parent session's model registry.",
});
}
const model = yield* Effect.try({
try: () =>
resolvePiModel(registry, task.model, task.parent.inheritedModel),
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
// pi's thinking levels ARE the shared reasoning-effort scale.
const thinkingLevel = (task.reasoningEffort ??
task.parent.inheritedThinkingLevel) as ThinkingLevel | undefined;
const session = yield* Effect.tryPromise({
try: async () => {
const { loader, settingsManager } = await createChildResources(
task.cwd,
task.parent.projectTrusted,
);
const { session } = await createAgentSession({
cwd: task.cwd,
sessionManager: SessionManager.create(task.cwd),
settingsManager,
resourceLoader: loader,
model,
thinkingLevel,
excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES],
});
// Start child extension session hooks/resources in headless mode.
// A rejection here would otherwise leak the freshly created session:
// the scope finalizer that owns cleanup is only registered later.
try {
await session.bindExtensions({ mode: "print" });
} catch (error) {
await shutdownAndDisposeChildSession(session);
throw error;
}
return session;
},
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
const state = {
closed: false,
/** prompt() rejection for the active run; folded into RunSettled. */
runError: undefined as string | undefined,
/** One terminal event per run: lifecycle, prompt-rejection, and abort
* fallbacks can all race to settle; the first wins. */
settled: false,
};
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const emit = (event: SubagentEvent) => {
Queue.offerUnsafe(events, event);
};
const toolTimeout = createToolCallTimeoutGuard();
toolTimeout.apply(session);
const activeModel = (): Model<any> | undefined => {
const sessionModel = session.model;
const last = lastAssistantMessage(session);
if (!last) return sessionModel;
if (
sessionModel &&
(last.provider !== sessionModel.provider ||
last.model !== sessionModel.id)
) {
// The session changed models after this assistant response.
return sessionModel;
}
return (
registry.find(last.provider, last.responseModel ?? last.model) ??
sessionModel
);
};
const currentMeta = (): SubagentMeta => {
const m = activeModel();
return {
backend: "pi",
modelLabel: m ? `${m.provider}/${m.id}` : undefined,
contextWindow: m?.contextWindow,
sessionFilePath: session.sessionFile,
};
};
const emitUsage = () => {
const usage = session.getContextUsage();
emit({
_tag: "UsageChanged",
tokens: usage?.tokens ?? undefined,
contextWindow: activeModel()?.contextWindow ?? usage?.contextWindow,
});
};
const settle = () => {
if (state.settled) return;
state.settled = true;
const last = lastAssistantMessage(session);
const partialText = finalOutput(session) || undefined;
if (last?.stopReason === "aborted") {
emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted", partialText },
});
return;
}
const errorText =
state.runError ??
(last?.stopReason === "error"
? (last.errorMessage ?? "Run failed")
: undefined);
if (errorText !== undefined) {
emit({
_tag: "RunSettled",
outcome: {
_tag: "Failed",
errorText: boundedError(errorText),
partialText,
},
});
return;
}
emit({
_tag: "RunSettled",
outcome: { _tag: "Completed", finalText: finalOutput(session) },
});
};
const handleEvent = (event: AgentSessionEvent) => {
if (state.closed) return;
switch (event.type) {
case "agent_start":
// Extensions may register tools between runs; guard new ones too.
toolTimeout.apply(session);
state.settled = false;
emit({ _tag: "RunStarted" });
break;
case "message_update": {
const streamEvent = event.assistantMessageEvent;
if (streamEvent.type === "text_delta") {
emit({
_tag: "AssistantDelta",
kind: "text",
delta: streamEvent.delta,
});
} else if (streamEvent.type === "thinking_delta") {
emit({
_tag: "AssistantDelta",
kind: "thinking",
delta: streamEvent.delta,
});
}
break;
}
case "message_end": {
const role = messageRole(event.message);
if (role === "user") {
const text = userText(event.message as Message);
if (text.trim()) emit({ _tag: "UserMessage", text });
} else if (role === "assistant") {
emit({
_tag: "AssistantMessage",
parts: assistantParts(event.message as AssistantMessage),
});
emitUsage();
emit({ _tag: "MetaChanged", meta: currentMeta() });
}
// toolResult messages are covered by tool_execution_end.
break;
}
case "tool_execution_start":
emit({
_tag: "ToolStart",
toolId: event.toolCallId,
name: event.toolName,
argsPreview: safeJson(event.args),
});
break;
case "tool_execution_update":
emit({
_tag: "ToolUpdate",
toolId: event.toolCallId,
outputPreview: toolPreview(event.partialResult),
});
break;
case "tool_execution_end":
emit({
_tag: "ToolEnd",
toolId: event.toolCallId,
name: event.toolName,
isError: event.isError,
outputPreview: toolPreview(event.result),
});
break;
case "queue_update":
emit({
_tag: "QueueChanged",
queued: [
...event.steering.map((text) => ({
text,
kind: "steer" as const,
})),
...event.followUp.map((text) => ({
text,
kind: "follow-up" as const,
})),
],
});
break;
case "agent_settled":
settle();
break;
}
};
const unsubscribe = session.subscribe(handleEvent);
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
state.closed = true;
unsubscribe();
try {
session.clearQueue();
} catch {
// Continue with abort/dispose.
}
await waitBounded(session.abort(), CHILD_SHUTDOWN_TIMEOUT_MS);
await shutdownAndDisposeChildSession(session);
Queue.endUnsafe(events);
}),
);
/** Start a fresh run (v1 manager.run): fire-and-forget, errors -> events. */
const startRun = (text: string) => {
state.runError = undefined;
state.settled = false;
emit({ _tag: "RunStarted" });
void session.prompt(text).catch((error) => {
state.runError = boundedError(error);
// Preflight failures may never start the agent lifecycle, so no
// agent_settled will arrive for them.
if (!session.isStreaming) settle();
});
};
// Session naming is best-effort.
yield* Effect.try(() =>
session.sessionManager.appendSessionInfo(
`${task.origin === "btw" ? "btw" : "subagent"}: ${task.title}`,
),
).pipe(Effect.ignore);
emit({ _tag: "MetaChanged", meta: currentMeta() });
startRun(task.prompt);
return {
meta: Effect.sync(currentMeta),
events: Stream.fromQueue(events),
send: (text) =>
Effect.suspend((): Effect.Effect<void, SendError> => {
if (state.closed) {
return new SendError({ message: "Subagent session is closed." });
}
if (session.isStreaming) {
// Steer the active run via the SDK's queue; queue_update events
// render it, message_end(user) lands it in the transcript. A
// rejected steer is a real send failure, not a diagnostic.
return Effect.tryPromise({
try: () => session.steer(text),
catch: (error) => new SendError({ message: boundedError(error) }),
}).pipe(Effect.asVoid);
}
return Effect.sync(() => startRun(text));
}),
interrupt: Effect.promise(async () => {
if (state.closed) return;
try {
session.clearQueue();
} catch {
// Abort regardless.
}
await session.abort().catch(() => undefined);
// Only resolve once streaming has actually stopped: reporting the
// interrupt as complete while the run keeps working would let the
// manager settle a run that is still mutating the workspace. The
// manager bounds this effect at 5s and force-disposes on timeout.
while (!state.closed && session.isStreaming) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
// No streaming run means no agent_settled will arrive; emit the
// terminal event (once) so the run cannot look running forever.
if (!state.closed && !state.settled) {
state.settled = true;
emit({ _tag: "RunSettled", outcome: { _tag: "Interrupted" } });
}
}),
} satisfies SubagentSession;
});
export const piBackend: SubagentBackend = {
name: "pi",
capabilities: { steering: true, modelSelection: true, reasoningEffort: true },
// In-process SDK: always available.
available: Effect.succeed(true),
spawn: makePiSession,
};
+300
View File
@@ -0,0 +1,300 @@
/**
* Scripted stub sessions shared by all three backend implementations while
* the real integrations are pending. A stub session:
*
* - streams a plausible turn (thinking deltas, one fake tool cycle, text
* deltas, usage ramp, a final assistant message, RunSettled) over a few
* seconds so streaming UI, wait, and the footer counters are observable;
* - supports send() while running (queued-steer rendering) and while idle
* (fresh run);
* - supports interrupt (RunSettled Interrupted -> status "error", matching v1);
* - fails the run when the prompt starts with "FAIL:" (error-path testing);
* - appends every event to a JSONL "session file" in tmpdir so the
* "full transcript in session file" pointers resolve.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { Cause, Scope } from "effect";
import { Duration, Effect, Fiber, Queue, Ref, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
BackendName,
QueuedMessage,
SpawnTask,
SubagentEvent,
SubagentMeta,
} from "../domain.ts";
import { SendError } from "../domain.ts";
export interface StubProfile {
readonly backend: BackendName;
readonly defaultModelLabel: string;
readonly contextWindow: number;
readonly toolName: string;
/** Delay between scripted events; varies per backend so streams differ. */
readonly cadenceMs: number;
}
const STUB_DIR = path.join(os.tmpdir(), "subagents-stub");
let sessionCounter = 0;
export function makeStubBackend(profile: StubProfile): SubagentBackend {
return {
name: profile.backend,
capabilities: {
steering: true,
modelSelection: true,
reasoningEffort: true,
},
// Real impls probe binary-on-PATH / SDK import / credentials here.
available: Effect.succeed(true),
spawn: (task) => makeStubSession(profile, task),
};
}
function firstLine(text: string): string {
return (
text
.split("\n")
.find((line) => line.trim())
?.trim() ?? ""
);
}
function chunked(text: string, size: number): string[] {
const chunks: string[] = [];
for (let i = 0; i < text.length; i += size)
chunks.push(text.slice(i, i + size));
return chunks;
}
const makeStubSession = (
profile: StubProfile,
task: SpawnTask,
): Effect.Effect<SubagentSession, never, Scope.Scope> =>
Effect.gen(function* () {
const sessionId = `stub-${profile.backend}-${++sessionCounter}`;
const sessionFile = path.join(STUB_DIR, `${sessionId}.jsonl`);
const state = {
meta: {
backend: profile.backend,
modelLabel: task.model ?? profile.defaultModelLabel,
contextWindow: profile.contextWindow,
sessionFilePath: sessionFile,
nativeSessionId: sessionId,
} satisfies SubagentMeta as SubagentMeta,
pending: [] as string[],
turnCount: 0,
closed: false,
/** True between the driver dequeuing a prompt and registering its turn fiber. */
dispatching: false,
};
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const inbox = yield* Queue.make<string, Cause.Done>();
const activeTurn = yield* Ref.make<Fiber.Fiber<void> | undefined>(
undefined,
);
const emit = (event: SubagentEvent) =>
Effect.suspend(() => {
try {
fs.appendFileSync(sessionFile, `${JSON.stringify(event)}\n`);
} catch {
// The fake session file is best-effort.
}
if (event._tag === "MetaChanged") {
state.meta = { ...state.meta, ...event.meta };
}
return Queue.offer(events, event);
}).pipe(Effect.asVoid);
const pause = Effect.sleep(Duration.millis(profile.cadenceMs));
const runTurn = (userText: string, turn: number) =>
Effect.gen(function* () {
yield* emit({ _tag: "RunStarted" });
const failing = userText.trimStart().startsWith("FAIL:");
const thinking = "Looking at the task and planning an approach...";
for (const delta of chunked(thinking, 16)) {
yield* emit({ _tag: "AssistantDelta", kind: "thinking", delta });
yield* pause;
}
const toolId = `${sessionId}-tool-${turn}`;
const argsPreview = `{"command":"ls ${task.cwd}"}`;
yield* emit({
_tag: "AssistantMessage",
parts: [
{ type: "thinking", text: thinking },
{
type: "text",
text: `I'll run ${profile.toolName} to look around first.`,
},
{ type: "toolCall", toolId, name: profile.toolName, argsPreview },
],
});
yield* emit({
_tag: "ToolStart",
toolId,
name: profile.toolName,
argsPreview,
});
yield* pause;
yield* emit({
_tag: "ToolUpdate",
toolId,
outputPreview: "src docs package.json",
});
yield* pause;
yield* emit({
_tag: "ToolEnd",
toolId,
name: profile.toolName,
isError: false,
outputPreview: "src docs package.json",
});
yield* emit({
_tag: "UsageChanged",
tokens: Math.min(profile.contextWindow, 2400 * (turn + 1)),
contextWindow: profile.contextWindow,
});
if (failing) {
yield* pause;
yield* emit({
_tag: "RunSettled",
outcome: {
_tag: "Failed",
errorText: `[stub:${profile.backend}] task failed as requested by FAIL: prefix`,
},
});
return;
}
const finalText =
`[stub:${profile.backend}] completed: ${firstLine(userText).slice(0, 200)}\n\n` +
`This is a stubbed ${profile.backend} subagent turn ${turn + 1}. ` +
`The real backend integration will replace this scripted output.`;
for (const delta of chunked(finalText, 24)) {
yield* emit({ _tag: "AssistantDelta", kind: "text", delta });
yield* pause;
}
yield* emit({
_tag: "AssistantMessage",
parts: [{ type: "text", text: finalText }],
});
yield* emit({
_tag: "UsageChanged",
tokens: Math.min(profile.contextWindow, 2400 * (turn + 1) + 900),
contextWindow: profile.contextWindow,
});
yield* emit({
_tag: "RunSettled",
outcome: { _tag: "Completed", finalText },
});
});
const queuedView = (): ReadonlyArray<QueuedMessage> =>
state.pending.map((text) => ({ text, kind: "steer" as const }));
// Driver: one turn at a time, in submission order. Turns run as child
// fibers so interrupt() stops the turn without killing the driver.
const driver = Effect.gen(function* () {
while (true) {
const text = yield* Queue.take(inbox);
state.dispatching = true;
state.pending.shift();
yield* emit({ _tag: "QueueChanged", queued: queuedView() });
yield* emit({ _tag: "UserMessage", text });
const turn = state.turnCount++;
const fiber = yield* Effect.forkChild(
runTurn(text, turn).pipe(
Effect.onInterrupt(() =>
emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted" },
}).pipe(Effect.ignore),
),
),
);
yield* Ref.set(activeTurn, fiber);
state.dispatching = false;
yield* Fiber.await(fiber);
yield* Ref.set(activeTurn, undefined);
}
});
yield* Effect.forkScoped(driver.pipe(Effect.ignore));
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
state.closed = true;
yield* Queue.end(inbox).pipe(Effect.ignore);
yield* Queue.end(events).pipe(Effect.ignore);
}),
);
const submit = (text: string) =>
Effect.gen(function* () {
if (state.closed) {
return yield* new SendError({
message: "Subagent session is closed.",
});
}
state.pending.push(text);
const busy = (yield* Ref.get(activeTurn)) !== undefined;
if (busy) {
// Show the queued steer line until the driver picks it up.
yield* emit({ _tag: "QueueChanged", queued: queuedView() });
}
yield* Queue.offer(inbox, text);
});
// Announce metadata, then kick off the initial run.
yield* Effect.try(() => fs.mkdirSync(STUB_DIR, { recursive: true })).pipe(
Effect.ignore, // The fake session file directory is best-effort.
);
yield* emit({ _tag: "MetaChanged", meta: state.meta });
// The session cannot be closed yet, so the initial submit cannot fail.
yield* submit(task.prompt).pipe(Effect.orDie);
return {
meta: Effect.sync(() => state.meta),
events: Stream.fromQueue(events),
send: submit,
interrupt: Effect.gen(function* () {
// Drop queued prompts so interrupting cannot immediately start
// another turn, then stop the active turn. A prompt may be mid-flight
// between the driver dequeuing it and registering its fiber, so wait
// that window out instead of silently missing the turn.
const cleared = yield* Queue.clear(inbox).pipe(
Effect.orElseSucceed(() => []),
);
state.pending = [];
yield* emit({ _tag: "QueueChanged", queued: [] });
while (true) {
const fiber = yield* Ref.get(activeTurn);
if (fiber) {
yield* Fiber.interrupt(fiber);
return;
}
if (!state.dispatching) {
// No turn ever started. If we cancelled queued prompts, the run
// still needs a terminal event or it would look running forever.
if (cleared.length > 0) {
yield* emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted" },
});
}
return;
}
yield* Effect.sleep(Duration.millis(5));
}
}),
} satisfies SubagentSession;
});
+21
View File
@@ -0,0 +1,21 @@
import type { SubagentOrigin } from "./domain.ts";
export const BTW_TITLE_MAX_LENGTH = 60;
/** Build a compact dashboard title from the first non-empty prompt line. */
export function deriveBtwTitle(prompt: string) {
const firstLine = prompt
.split("\n")
.find((line) => line.trim())
?.trim();
const title = firstLine?.replace(/\s+/g, " ") ?? "";
if (!title) return "by the way";
const codePoints = Array.from(title);
if (codePoints.length <= BTW_TITLE_MAX_LENGTH) return title;
return `${codePoints.slice(0, BTW_TITLE_MAX_LENGTH - 1).join("")}…`;
}
/** User asides remain visible in the dashboard but hidden from model tools. */
export function isModelVisible(snap: { readonly origin: SubagentOrigin }) {
return snap.origin === "model";
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Domain model for subagents.
*
* Everything downstream of a backend (manager, tools, UI) speaks only these
* types. Backends translate their native streams (pi session events, Claude
* Agent SDK messages, Codex app-server JSON-RPC notifications) into the
* normalized `SubagentEvent` union.
*/
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
import { Data } from "effect";
export const BACKEND_NAMES = ["pi", "claude", "codex"] as const;
export type BackendName = (typeof BACKEND_NAMES)[number];
/** Who initiated the session. User asides stay out of model-facing tooling. */
export type SubagentOrigin = "model" | "btw";
/**
* Shared reasoning-effort scale (pi's thinking levels). Each backend maps a
* value to its nearest native equivalent: pi uses it directly, codex
* translates to its reasoning-effort slugs, claude translates to thinking
* budgets. Omitted = backend default (pi inherits the parent level).
*/
export const REASONING_EFFORTS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
export type SubagentStatus = "running" | "done" | "error";
/** Parent-session context resolved by the tool layer and passed opaquely. */
export interface ParentContext {
readonly parentCwd: string;
readonly projectTrusted: boolean;
/** Parent pi model, for the pi backend's "inherit" default. */
readonly inheritedModel?: { readonly provider: string; readonly id: string };
readonly inheritedThinkingLevel?: string;
/** Parent model registry; required by the pi backend to resolve models. */
readonly modelRegistry?: ModelRegistry;
}
export interface SpawnTask {
/** Omitted for normal tool-driven spawns. */
readonly origin?: SubagentOrigin;
readonly prompt: string;
readonly title: string;
readonly cwd: string;
/**
* Generic model hint, interpreted per backend:
* pi: "provider/model-id" or bare model id; claude: model alias;
* codex: model slug. Omitted = backend default / inherit.
*/
readonly model?: string;
/** Shared effort scale; each backend maps it to its native equivalent. */
readonly reasoningEffort?: ReasoningEffort;
readonly parent: ParentContext;
}
export interface SubagentMeta {
readonly backend: BackendName;
/** Display label, e.g. "anthropic/claude-opus-4-5" or "gpt-5-codex". */
readonly modelLabel?: string;
/** Context window capacity for utilization display, when known. */
readonly contextWindow?: number;
/** pi session file / Claude projects JSONL / Codex rollout path. */
readonly sessionFilePath?: string;
/** Claude session id / Codex conversation id. */
readonly nativeSessionId?: string;
}
// --- Transcript ------------------------------------------------------------
export type TranscriptPart =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "thinking";
readonly text: string;
readonly redacted?: boolean;
}
| {
readonly type: "toolCall";
readonly toolId: string;
readonly name: string;
readonly argsPreview?: string;
};
export type TranscriptItem =
| { readonly kind: "user"; readonly text: string }
| {
readonly kind: "assistant";
readonly parts: ReadonlyArray<TranscriptPart>;
}
| {
readonly kind: "toolResult";
readonly toolId: string;
readonly name: string;
readonly isError: boolean;
readonly outputPreview?: string;
};
export interface LiveToolState {
readonly toolId: string;
readonly name: string;
readonly argsPreview?: string;
readonly outputPreview?: string;
readonly done?: boolean;
readonly isError?: boolean;
}
export interface QueuedMessage {
readonly text: string;
readonly kind: "steer" | "follow-up";
}
// --- Events ------------------------------------------------------------------
export type RunOutcome =
| { readonly _tag: "Completed"; readonly finalText: string }
| {
readonly _tag: "Failed";
readonly errorText: string;
readonly partialText?: string;
}
| { readonly _tag: "Interrupted"; readonly partialText?: string };
/**
* Normalized activity stream. Previews (`argsPreview`, `outputPreview`) are
* pre-flattened single-line strings because the UI only ever renders one
* sanitized line, which keeps three different native tool-result shapes out
* of the interface.
*/
export type SubagentEvent =
// lifecycle (a session can run multiple turns via send())
| { readonly _tag: "RunStarted" }
| { readonly _tag: "RunSettled"; readonly outcome: RunOutcome }
// transcript building blocks
| { readonly _tag: "UserMessage"; readonly text: string }
| {
readonly _tag: "AssistantDelta";
readonly kind: "text" | "thinking";
readonly delta: string;
}
| {
readonly _tag: "AssistantMessage";
readonly parts: ReadonlyArray<TranscriptPart>;
}
| {
readonly _tag: "ToolStart";
readonly toolId: string;
readonly name: string;
readonly argsPreview?: string;
}
| {
readonly _tag: "ToolUpdate";
readonly toolId: string;
readonly outputPreview?: string;
}
| {
readonly _tag: "ToolEnd";
readonly toolId: string;
readonly name: string;
readonly isError: boolean;
readonly outputPreview?: string;
}
// bookkeeping
| {
readonly _tag: "QueueChanged";
readonly queued: ReadonlyArray<QueuedMessage>;
}
| {
readonly _tag: "UsageChanged";
readonly tokens?: number;
readonly contextWindow?: number;
}
| { readonly _tag: "MetaChanged"; readonly meta: Partial<SubagentMeta> }
/** Non-fatal diagnostics. Fatal failures arrive as a RunSettled outcome. */
| { readonly _tag: "BackendError"; readonly message: string };
// --- Snapshot ---------------------------------------------------------------
/**
* The manager folds `SubagentEvent`s into one snapshot per subagent. This is
* everything the tools, footer status, and both TUI views read.
*/
export interface SubagentSnapshot {
readonly id: string;
readonly origin: SubagentOrigin;
readonly backend: BackendName;
readonly title: string;
readonly prompt: string;
readonly cwd: string;
readonly status: SubagentStatus;
readonly createdAt: number;
readonly settledAt?: number;
readonly errorText?: string;
readonly meta: SubagentMeta;
readonly usage: { readonly tokens?: number; readonly contextWindow?: number };
readonly transcript: ReadonlyArray<TranscriptItem>;
/** Streaming assistant buffers, cleared when the finalized message lands. */
readonly liveAssistant?: { readonly text: string; readonly thinking: string };
readonly liveTools: ReadonlyArray<LiveToolState>;
readonly queued: ReadonlyArray<QueuedMessage>;
/** Final text of the most recent completed run (v1 `finalOutput`). */
readonly finalText: string;
/** Count of finalized assistant messages (for subagent_check). */
readonly turns: number;
}
/** Final text, or the live streaming buffer while a run is active (v1 `latestOutput`). */
export function latestText(snap: SubagentSnapshot) {
const live = snap.liveAssistant?.text.trim();
if (live) return live;
return snap.finalText;
}
export function formatElapsed(snap: SubagentSnapshot) {
const end = snap.settledAt ?? Date.now();
const totalSeconds = Math.max(0, Math.round((end - snap.createdAt) / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0
? `${minutes}m${seconds.toString().padStart(2, "0")}s`
: `${seconds}s`;
}
// --- Errors -------------------------------------------------------------------
export class SpawnError extends Data.TaggedError("SpawnError")<{
readonly message: string;
}> {}
export class BackendUnavailableError extends Data.TaggedError(
"BackendUnavailableError",
)<{
readonly message: string;
}> {}
export class ConcurrencyLimitError extends Data.TaggedError(
"ConcurrencyLimitError",
)<{
readonly message: string;
}> {}
export class SendError extends Data.TaggedError("SendError")<{
readonly message: string;
}> {}
+74
View File
@@ -0,0 +1,74 @@
/**
* Formatting helpers (self-contained copies of the v1 shared helpers:
* context-utilization + activity-status).
*/
import type { Theme } from "@earendil-works/pi-coding-agent";
export interface ContextUtilization {
/** Current conversation context occupancy; undefined while unknown. */
tokens?: number | null;
/** Capacity of the model currently serving the conversation. */
contextWindow?: number | null;
}
function usableTokens(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined;
}
function usableCapacity(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: undefined;
}
export function contextPercent(usage: ContextUtilization) {
const tokens = usableTokens(usage.tokens);
const capacity = usableCapacity(usage.contextWindow);
if (tokens === undefined || capacity === undefined) return undefined;
return Math.round(Math.min(100, Math.max(0, (tokens / capacity) * 100)));
}
export function formatCompactTokens(count: number) {
if (count < 1000) return Math.round(count).toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
/**
* Render `%/capacity`. If occupancy is unknown, retain the useful capacity
* as `?%/capacity`; with no valid capacity, omit the statistic entirely.
*/
export function formatContextUtilization(usage: ContextUtilization) {
const capacity = usableCapacity(usage.contextWindow);
if (capacity === undefined) return "";
const percent = contextPercent(usage);
return `${percent === undefined ? "?" : percent}%/${formatCompactTokens(capacity)}`;
}
interface ActivityCounts {
running: number;
done: number;
failed: number;
}
const SQUARE = "■";
export function formatActivityStatus(theme: Theme, counts: ActivityCounts) {
const parts: string[] = [];
if (counts.running > 0) {
parts.push(theme.fg("warning", `${SQUARE} ${counts.running} running`));
}
if (counts.done > 0) {
parts.push(theme.fg("success", `${SQUARE} ${counts.done} done`));
}
if (counts.failed > 0) {
parts.push(theme.fg("error", `${SQUARE} ${counts.failed} failed`));
}
parts.push(theme.fg("accent", "/subagents") + theme.fg("dim", " to view"));
return `${theme.fg("muted", "subagents:")} ${parts.join(theme.fg("dim", " · "))}`;
}
+736
View File
@@ -0,0 +1,736 @@
/**
* SubagentManager — owns the registry of running/finished subagents.
*
* Each subagent is a scoped `SubagentSession` from a `SubagentBackend` plus a
* pump fiber that folds its normalized event stream into a mutable
* `SubagentSnapshot`. Closing a subagent's scope kills the underlying
* session/process and stops the pump.
*
* The manager also exposes a synchronous `SubagentReadModel` so the
* imperative TUI components (which render synchronously) can read snapshots
* and issue fire-and-forget commands without touching the Effect runtime.
*/
import {
Context,
Effect,
Exit,
Fiber,
Layer,
Result,
Scope,
Stream,
} from "effect";
import type { SubagentBackend, SubagentSession } from "./backend.ts";
import { BackendRegistry } from "./backend.ts";
import type {
BackendName,
LiveToolState,
RunOutcome,
SpawnTask,
SubagentEvent,
SubagentOrigin,
SubagentMeta,
SubagentSnapshot,
SubagentStatus,
TranscriptItem,
} from "./domain.ts";
import {
BackendUnavailableError,
ConcurrencyLimitError,
SendError,
SpawnError,
} from "./domain.ts";
export const MAX_RUNNING = 4;
export const MAX_TRACKED = 64;
const STOP_TIMEOUT_MS = 5_000;
const ERROR_TEXT_MAX_LENGTH = 4_096;
const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024;
const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024;
const FINAL_TEXT_MAX_LENGTH = 1_024 * 1_024;
const MAX_TRANSCRIPT_ITEMS = 512;
function bounded(text: string) {
return text.slice(0, ERROR_TEXT_MAX_LENGTH);
}
function boundedTranscriptText(text: string) {
return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH);
}
function appendTranscript(snapshot: MutableSnapshot, item: TranscriptItem) {
snapshot.transcript.push(item);
if (snapshot.transcript.length > MAX_TRANSCRIPT_ITEMS) {
snapshot.transcript.splice(
0,
snapshot.transcript.length - MAX_TRANSCRIPT_ITEMS,
);
}
}
// --- Internal state -----------------------------------------------------------
/** Mutable snapshot; exposed to readers via the readonly SubagentSnapshot type. */
interface MutableSnapshot {
id: string;
origin: SubagentOrigin;
backend: BackendName;
title: string;
prompt: string;
cwd: string;
status: SubagentStatus;
createdAt: number;
settledAt?: number;
errorText?: string;
meta: SubagentMeta;
usage: { tokens?: number; contextWindow?: number };
transcript: TranscriptItem[];
liveAssistant?: { text: string; thinking: string };
liveTools: LiveToolState[];
queued: SubagentSnapshot["queued"];
finalText: string;
turns: number;
}
interface Entry {
snapshot: MutableSnapshot;
session: SubagentSession;
scope: Scope.Closeable;
pump?: Fiber.Fiber<void>;
liveToolMap: Map<string, LiveToolState>;
/** Idle restart dispatched but RunStarted not folded yet; counts as running
* so concurrent restarts cannot race past the cap. */
restarting?: boolean;
}
// --- Read model ----------------------------------------------------------------
/** Synchronous bridge for the TUI. Snapshots are live objects; do not mutate. */
export interface SubagentReadModel {
list(): ReadonlyArray<SubagentSnapshot>;
get(id: string): SubagentSnapshot | undefined;
size(): number;
/** Any-change notification (footer status, dashboard). */
subscribe(listener: () => void): () => void;
/** Per-subagent notification (takeover view). */
subscribeTo(id: string, listener: () => void): () => void;
/** Fire-and-forget: steer/continue a subagent (takeover input). */
requestSend(id: string, text: string): void;
/** Fire-and-forget: abort a running subagent (dashboard `x`, takeover). */
requestAbort(id: string): void;
/**
* Register the settle hook. `consumed` is true when an active
* subagent_wait/cancel is collecting the result (so it must not also be
* delivered as a follow-up message).
*/
setOnSettled(
hook: ((snap: SubagentSnapshot, consumed: boolean) => void) | undefined,
): void;
}
// --- Service --------------------------------------------------------------------
export interface CancelResult {
readonly id: string;
readonly title: string;
readonly status: SubagentStatus;
readonly cancelled: boolean;
}
export interface SubagentManagerShape {
spawn(
backend: BackendName,
task: SpawnTask,
): Effect.Effect<
SubagentSnapshot,
SpawnError | ConcurrencyLimitError | BackendUnavailableError
>;
/**
* Wait until all listed subagents are settled. Unknown ids are treated as
* settled (the tool layer validates ids first). While waiting, settles for
* these ids are marked "consumed". Interruption (tool abort) releases the
* interest and leaves the subagents running.
*/
waitFor(
ids: ReadonlyArray<string>,
onPending?: (pending: string[]) => void,
): Effect.Effect<void>;
/** Cancel running subagents; resolves when they have settled. */
cancel(
ids: ReadonlyArray<string>,
): Effect.Effect<ReadonlyArray<CancelResult>>;
send(id: string, text: string): Effect.Effect<void, SendError>;
get(id: string): Effect.Effect<SubagentSnapshot | undefined>;
readonly list: Effect.Effect<ReadonlyArray<SubagentSnapshot>>;
readonly disposeAll: Effect.Effect<void>;
readonly view: SubagentReadModel;
}
export class SubagentManager extends Context.Service<
SubagentManager,
SubagentManagerShape
>()("subagents/SubagentManager") {}
// --- Implementation --------------------------------------------------------------
const makeManager = Effect.gen(function* () {
const registry = yield* BackendRegistry;
// Detached forker for sync contexts (read-model commands, pruning) that
// preserves the manager's services instead of using the global runtime.
const runDetached = Effect.runForkWith(yield* Effect.context());
const entries = new Map<string, Entry>();
const waitInterest = new Map<string, number>();
const listeners = new Set<() => void>();
/** One-shot nextChange waiters, swapped out before invocation so waiters
* re-registering during notification are not visited in the same sweep. */
let changeWaiters: Array<() => void> = [];
const idListeners = new Map<string, Set<() => void>>();
const cleanups = new Set<Fiber.Fiber<unknown>>();
let modelCounter = 0;
let btwCounter = 0;
let reserved = 0;
let disposed = false;
let onSettled:
((snap: SubagentSnapshot, consumed: boolean) => void) | undefined;
const notify = (id?: string) => {
const waiters = changeWaiters;
changeWaiters = [];
for (const waiter of waiters) waiter();
for (const listener of [...listeners]) {
try {
listener();
} catch {
// A failed status/render listener must not corrupt lifecycle state.
}
}
if (id) {
for (const listener of idListeners.get(id) ?? []) {
try {
listener();
} catch {
// Same.
}
}
}
};
/** Resolves on the next state change. Interruption unregisters the waiter. */
const nextChange = Effect.callback<void>((resume) => {
const waiter = () => resume(Effect.void);
changeWaiters.push(waiter);
return Effect.sync(() => {
const index = changeWaiters.indexOf(waiter);
if (index >= 0) changeWaiters.splice(index, 1);
});
});
const runningCount = () =>
[...entries.values()].filter(
(e) => e.snapshot.status === "running" || e.restarting === true,
).length;
const addInterest = (ids: ReadonlyArray<string>) => {
for (const id of ids) waitInterest.set(id, (waitInterest.get(id) ?? 0) + 1);
};
const releaseInterest = (ids: ReadonlyArray<string>) => {
for (const id of ids) {
const count = (waitInterest.get(id) ?? 1) - 1;
if (count <= 0) waitInterest.delete(id);
else waitInterest.set(id, count);
}
};
const closeEntryScope = (entry: Entry) =>
Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
const pruneSettled = () => {
if (entries.size <= MAX_TRACKED) return;
const candidates = [...entries.values()]
.filter(
(e) =>
e.snapshot.status !== "running" && !waitInterest.has(e.snapshot.id),
)
.sort(
(a, b) =>
(a.snapshot.settledAt ?? a.snapshot.createdAt) -
(b.snapshot.settledAt ?? b.snapshot.createdAt),
);
for (const entry of candidates) {
if (entries.size <= MAX_TRACKED) break;
entries.delete(entry.snapshot.id);
const fiber = runDetached(closeEntryScope(entry));
cleanups.add(fiber);
fiber.addObserver(() => cleanups.delete(fiber));
}
};
const settle = (entry: Entry, outcome: RunOutcome) => {
const s = entry.snapshot;
entry.restarting = false;
if (s.status !== "running") return;
s.settledAt = Date.now();
switch (outcome._tag) {
case "Completed":
s.status = "done";
s.errorText = undefined;
s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
break;
case "Failed":
s.status = "error";
s.errorText = bounded(outcome.errorText);
// Never let a failed run report the previous run's successful output.
s.finalText = (outcome.partialText ?? "").slice(
0,
FINAL_TEXT_MAX_LENGTH,
);
break;
case "Interrupted":
s.status = "error";
s.errorText = "Run was aborted";
s.finalText = (outcome.partialText ?? "").slice(
0,
FINAL_TEXT_MAX_LENGTH,
);
break;
}
s.liveAssistant = undefined;
entry.liveToolMap.clear();
s.liveTools = [];
s.queued = [];
const consumed = (waitInterest.get(s.id) ?? 0) > 0;
notify(s.id);
try {
// During teardown, don't queue results into a shutting-down session.
if (!disposed) onSettled?.(s, consumed);
} catch {
// The parent session may be unavailable; settlement stays final.
}
pruneSettled();
};
const foldEvent = (entry: Entry, event: SubagentEvent) => {
const s = entry.snapshot;
switch (event._tag) {
case "RunStarted":
entry.restarting = false;
s.status = "running";
s.settledAt = undefined;
s.errorText = undefined;
break;
case "RunSettled":
settle(entry, event.outcome);
return; // settle() already notified
case "UserMessage":
appendTranscript(s, {
kind: "user",
text: boundedTranscriptText(event.text),
});
break;
case "AssistantDelta": {
const live = s.liveAssistant ?? { text: "", thinking: "" };
s.liveAssistant =
event.kind === "text"
? {
...live,
text: (live.text + event.delta).slice(
-LIVE_ASSISTANT_MAX_LENGTH,
),
}
: {
...live,
thinking: (live.thinking + event.delta).slice(
-LIVE_ASSISTANT_MAX_LENGTH,
),
};
break;
}
case "AssistantMessage":
appendTranscript(s, {
kind: "assistant",
parts: event.parts.map((part) =>
part.type === "toolCall"
? {
...part,
argsPreview: part.argsPreview
? boundedTranscriptText(part.argsPreview)
: undefined,
}
: { ...part, text: boundedTranscriptText(part.text) },
),
});
s.liveAssistant = undefined;
s.turns++;
break;
case "ToolStart":
entry.liveToolMap.set(event.toolId, {
toolId: event.toolId,
name: event.name,
argsPreview: event.argsPreview
? boundedTranscriptText(event.argsPreview)
: undefined,
});
s.liveTools = [...entry.liveToolMap.values()];
break;
case "ToolUpdate": {
const current = entry.liveToolMap.get(event.toolId);
if (current) {
entry.liveToolMap.set(event.toolId, {
...current,
outputPreview: event.outputPreview
? boundedTranscriptText(event.outputPreview)
: current.outputPreview,
});
s.liveTools = [...entry.liveToolMap.values()];
}
break;
}
case "ToolEnd":
entry.liveToolMap.delete(event.toolId);
s.liveTools = [...entry.liveToolMap.values()];
appendTranscript(s, {
kind: "toolResult",
toolId: event.toolId,
name: event.name,
isError: event.isError,
outputPreview: event.outputPreview
? boundedTranscriptText(event.outputPreview)
: undefined,
});
break;
case "QueueChanged":
s.queued = event.queued;
break;
case "UsageChanged":
s.usage = {
tokens: event.tokens ?? s.usage.tokens,
contextWindow: event.contextWindow ?? s.usage.contextWindow,
};
break;
case "MetaChanged":
s.meta = { ...s.meta, ...event.meta };
break;
case "BackendError":
s.errorText = bounded(event.message);
break;
}
notify(s.id);
};
const spawn = (backendName: BackendName, task: SpawnTask) =>
Effect.gen(function* () {
// Reserve synchronously (before the first yield inside doSpawn) so
// parallel tool calls cannot race past the global cap.
yield* Effect.suspend(
(): Effect.Effect<void, SpawnError | ConcurrencyLimitError> => {
if (disposed) {
return new SpawnError({
message: "Subagent manager is shutting down.",
});
}
if (runningCount() + reserved >= MAX_RUNNING) {
return new ConcurrencyLimitError({
message: `Max ${MAX_RUNNING} subagents can run concurrently. Wait for one to finish before spawning another.`,
});
}
reserved++;
return Effect.void;
},
);
const doSpawn = Effect.gen(function* () {
const backend: SubagentBackend | undefined = registry.get(backendName);
if (!backend) {
return yield* new BackendUnavailableError({
message: `Unknown backend "${backendName}".`,
});
}
const available = yield* backend.available;
if (!available) {
return yield* new BackendUnavailableError({
message: `Backend "${backendName}" is not available on this machine (binary/SDK/credentials missing).`,
});
}
const scope = yield* Scope.make();
const session = yield* Scope.provide(backend.spawn(task), scope).pipe(
Effect.onError(() => Scope.close(scope, Exit.void)),
);
if (disposed) {
yield* Scope.close(scope, Exit.void);
return yield* new SpawnError({
message: "Subagent manager shut down while spawning.",
});
}
const origin = task.origin ?? "model";
const id =
origin === "btw" ? `btw-${++btwCounter}` : `sa-${++modelCounter}`;
const meta = yield* session.meta;
const entry: Entry = {
snapshot: {
id,
origin,
backend: backendName,
title: task.title,
prompt: task.prompt,
cwd: task.cwd,
status: "running",
createdAt: Date.now(),
meta,
usage: { contextWindow: meta.contextWindow },
transcript: [],
liveTools: [],
queued: [],
finalText: "",
turns: 0,
},
session,
scope,
liveToolMap: new Map(),
};
entries.set(id, entry);
// Pump: fold the event stream into the snapshot. Tied to the entry
// scope, so closing the scope stops it. If the stream ends while the
// subagent still looks running, the backend died out from under us.
const pump = Stream.runForEach(session.events, (event) =>
Effect.sync(() => foldEvent(entry, event)),
).pipe(
Effect.ensuring(
Effect.sync(() => {
if (entry.snapshot.status === "running") {
settle(entry, {
_tag: "Failed",
errorText: "Backend event stream ended unexpectedly",
});
}
}),
),
);
entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope);
notify(id);
return entry.snapshot as SubagentSnapshot;
});
return yield* doSpawn.pipe(
Effect.ensuring(
Effect.sync(() => {
reserved--;
notify();
}),
),
);
});
const waitFor = (
ids: ReadonlyArray<string>,
onPending?: (pending: string[]) => void,
) =>
Effect.suspend(() => {
const unique = [...new Set(ids)];
addInterest(unique);
const loop = Effect.gen(function* () {
while (true) {
const pending = unique.filter(
(id) => entries.get(id)?.snapshot.status === "running",
);
if (pending.length === 0) return;
onPending?.(pending);
yield* nextChange;
}
});
return loop.pipe(
Effect.ensuring(
Effect.sync(() => {
releaseInterest(unique);
pruneSettled();
}),
),
);
});
/** Interrupt one running entry, force-closing its scope after 5s. */
const abortEntry = (entry: Entry) =>
Effect.gen(function* () {
if (entry.snapshot.status !== "running") return;
const graceful = yield* entry.session.interrupt.pipe(
Effect.timeout(STOP_TIMEOUT_MS),
Effect.result,
);
if (Result.isFailure(graceful)) {
// Settle before closing the scope so the pump's stream-ended
// fallback ("Backend event stream ended unexpectedly") cannot win
// the race and report the wrong terminal reason.
yield* Effect.sync(() => {
settle(entry, { _tag: "Interrupted" });
entry.snapshot.errorText =
"Abort deadline exceeded; session was force-disposed";
notify(entry.snapshot.id);
});
// Bound the close like disposeAll does: a stuck backend finalizer
// must not hang cancel after the run is already settled.
yield* closeEntryScope(entry).pipe(
Effect.timeout(STOP_TIMEOUT_MS),
Effect.ignore,
);
}
});
const cancel = (ids: ReadonlyArray<string>) =>
Effect.suspend(() => {
const unique = [...new Set(ids)];
const running = unique
.map((id) => entries.get(id))
.filter(
(entry): entry is Entry => entry?.snapshot.status === "running",
);
const runningIds = running.map((entry) => entry.snapshot.id);
// Mark consumed before interrupting so cancellation does not also
// enqueue duplicate automatic result messages into the parent.
addInterest(runningIds);
const work = Effect.gen(function* () {
yield* Effect.forEach(running, abortEntry, {
concurrency: "unbounded",
});
while (running.some((entry) => entry.snapshot.status === "running")) {
yield* nextChange;
}
});
return work.pipe(
Effect.ensuring(
Effect.sync(() => {
releaseInterest(runningIds);
pruneSettled();
}),
),
Effect.map((): ReadonlyArray<CancelResult> =>
unique.map((id) => {
const snapshot = entries.get(id)?.snapshot;
return {
id,
title: snapshot?.title ?? "?",
status: snapshot?.status ?? "error",
cancelled: runningIds.includes(id),
};
}),
),
);
});
const send = (id: string, text: string) =>
Effect.suspend((): Effect.Effect<void, SendError> => {
const entry = entries.get(id);
if (!entry || disposed) {
return new SendError({
message: `Subagent "${id}" is no longer tracked.`,
});
}
// Restarting a settled subagent occupies a running slot again, so it
// must respect the same cap as spawn. Steering an already-running one
// does not consume additional capacity.
if (entry.snapshot.status !== "running") {
if (runningCount() + reserved >= MAX_RUNNING) {
return new SendError({
message: `Max ${MAX_RUNNING} subagents can run concurrently; restarting "${id}" would exceed that.`,
});
}
// Occupy the slot synchronously: the RunStarted that flips status
// arrives via the async pump, and two concurrent restarts must not
// both pass the check in that window. Cleared by RunStarted/settle,
// or here when the backend rejects the send.
entry.restarting = true;
return entry.session.send(text).pipe(
Effect.onError(() =>
Effect.sync(() => {
entry.restarting = false;
}),
),
);
}
return entry.session.send(text);
});
const disposeAll = Effect.gen(function* () {
disposed = true;
const all = [...entries.values()];
entries.clear();
yield* Effect.forEach(
all,
(entry) =>
closeEntryScope(entry).pipe(
Effect.timeout(STOP_TIMEOUT_MS),
Effect.ignore,
),
{ concurrency: "unbounded" },
);
// Pruning cleanups are detached; bound them like everything else so a
// stuck backend finalizer cannot block runtime shutdown indefinitely.
yield* Effect.forEach(
[...cleanups],
(fiber) =>
Fiber.await(fiber).pipe(Effect.timeout(STOP_TIMEOUT_MS), Effect.ignore),
{ concurrency: "unbounded" },
).pipe(Effect.ignore);
yield* Effect.sync(() => notify());
});
const view: SubagentReadModel = {
list: () => [...entries.values()].map((entry) => entry.snapshot),
get: (id) => entries.get(id)?.snapshot,
size: () => entries.size,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeTo: (id, listener) => {
let set = idListeners.get(id);
if (!set) {
set = new Set();
idListeners.set(id, set);
}
set.add(listener);
return () => {
set.delete(listener);
if (set.size === 0) idListeners.delete(id);
};
},
requestSend: (id, text) => {
runDetached(send(id, text).pipe(Effect.ignore));
},
requestAbort: (id) => {
const entry = entries.get(id);
if (!entry) return;
// UI-initiated aborts are not "consumed": the failed result still
// flows back to the parent as a follow-up message, matching v1.
runDetached(abortEntry(entry).pipe(Effect.ignore));
},
setOnSettled: (hook) => {
onSettled = hook;
},
};
// Safety net: disposing the ManagedRuntime tears everything down even if
// the extension forgot to call disposeAll explicitly.
yield* Effect.addFinalizer(() => disposeAll);
return SubagentManager.of({
spawn,
waitFor,
cancel,
send,
get: (id) => Effect.sync(() => entries.get(id)?.snapshot),
list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)),
disposeAll,
view,
});
});
export const SubagentManagerLive: Layer.Layer<
SubagentManager,
never,
BackendRegistry
> = Layer.effect(SubagentManager, makeManager);
+92
View File
@@ -0,0 +1,92 @@
/** All model-facing strings for the subagents tools. */
/** Describes subagent_spawn, including harnesses and the fixed concurrency cap. */
export const SUBAGENT_SPAWN_TOOL_DESCRIPTION =
"Spawn a background subagent: a fully autonomous, headless agent with its own context window and the selected harness's normal host permissions. You choose the harness it runs on: pi (in-process pi session, inherits this environment's tools and config), claude (Claude Code), or codex (Codex CLI). Fire-and-forget: this returns immediately with an id. The subagent's final output is queued back to you as a message when it settles, or collect it explicitly with subagent_wait. Children cannot orchestrate more agents/workflows or ask the user, and cannot see this conversation, so the prompt must be self-contained. Only use trusted working directories. Max 4 subagents can be running at once across all harnesses.";
/** Adds background subagent delegation to the parent model's available-tools prompt. */
export const SUBAGENT_SPAWN_PROMPT_SNIPPET =
"Spawn a background subagent on a chosen harness (pi, Claude Code, or Codex; own context, normal tools) for a self-contained task";
/** Guides the parent model to delegate standalone tasks and avoid unnecessary blocking waits. */
export const SUBAGENT_SPAWN_PROMPT_GUIDELINES = [
"Use subagent_spawn to delegate self-contained tasks that can run in the background; give it a complete, standalone prompt.",
"Pick the subagent harness deliberately: pi unless you have a reason to prefer Claude Code or Codex (e.g. the user asked for one, or the task suits that harness).",
"After subagent_spawn, keep working; results arrive automatically. Only call subagent_wait when you cannot proceed without the result.",
];
/** Model-facing schema descriptions for subagent_spawn task and execution options. */
export const SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS = {
prompt:
"Task prompt for the subagent. Must be self-contained: include all needed context, file paths, and what to report back.",
name: "Short human-readable name for this subagent, shown in listings and the UI",
harness:
'Harness to run the subagent on: "pi" (in-process pi session; inherits this environment), "claude" (Claude Code), or "codex" (Codex CLI). Choose deliberately per task.',
workingDir:
"Trusted working directory for the autonomous child (default: current working directory)",
model:
'Model hint, interpreted by the chosen harness (pi: "provider/model-id" or model id; claude: model alias like "sonnet"/"opus"; codex: model slug). Omit for the harness default (pi inherits the current model).',
reasoningEffort:
"Reasoning effort on a shared scale; the harness maps it to its nearest native equivalent (pi thinking level, codex reasoning effort, claude thinking budget). Omit for the harness default (pi inherits the current level).",
};
/** Builds the subagent_spawn result that tells the parent model how to continue or inspect the child. */
export function buildSubagentSpawnResult(options: {
id: string;
title: string;
harness: string;
modelLabel: string;
cwd: string;
}) {
return (
`Spawned subagent ${options.id} "${options.title}" (${options.harness}: ${options.modelLabel}, ${options.cwd}).\n` +
`It runs in the background. Its result will be delivered to you when it finishes, ` +
`or use subagent_wait(ids: ["${options.id}"]) to block for it, subagent_cancel to stop it, subagent_check to peek, subagent_list to see all.`
);
}
/** Describes explicit blocking collection of one or more subagent results. */
export const SUBAGENT_WAIT_TOOL_DESCRIPTION =
"Block until all listed subagents have settled, then return their final outputs. Prefer letting results arrive automatically; use this only when you need a result before continuing.";
/** Model-facing schema description for the subagent ids to await. */
export const SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS = {
ids: 'Subagent ids to wait for, e.g. ["sa-1", "sa-2"]',
};
/** Describes aborting running subagents while retaining their partial transcripts. */
export const SUBAGENT_CANCEL_TOOL_DESCRIPTION =
"Cancel one or more running subagents. This aborts their active work but preserves their partial session transcripts on disk.";
/** Model-facing schema description for the subagent ids to cancel. */
export const SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS = {
ids: 'Subagent ids to cancel, e.g. ["sa-1", "sa-2"]',
};
/** Describes nonblocking inspection of a subagent without consuming its result. */
export const SUBAGENT_CHECK_TOOL_DESCRIPTION =
"Peek at a subagent's status and recent activity without blocking. Does not consume its result.";
/** Model-facing schema description for the subagent id to inspect. */
export const SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS = {
id: "Subagent id",
};
/** Describes listing all tracked running and settled subagents. */
export const SUBAGENT_LIST_TOOL_DESCRIPTION =
"List all subagents (running and finished) with their harness and status.";
/** Builds the child completion/failure wrapper injected into the parent model's context. */
export function buildSubagentResultMessage(options: {
id: string;
title: string;
status: "running" | "done" | "error";
errorText?: string;
output: string;
}) {
const verb = options.status === "error" ? "failed" : "finished";
let text = `Subagent ${options.id} "${options.title}" ${verb}.`;
if (options.errorText) text += `\nError: ${options.errorText}`;
text += `\n\n${options.output}`;
return text;
}
@@ -0,0 +1,20 @@
export function createDeferredResultDelivery<T extends { id: string }>() {
const pending = new Map<string, T>();
return {
defer(result: T) {
pending.set(result.id, result);
},
consume(ids: Iterable<string>) {
for (const id of ids) pending.delete(id);
},
drain() {
const results = [...pending.values()];
pending.clear();
return results;
},
clear() {
pending.clear();
},
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Layer composition and the async entry-point boundary.
*
* Everything inside the extension is Effect generators; this module is where
* tool handlers (plain async functions) run those effects against one shared
* ManagedRuntime.
*/
import { Cause, Exit, Layer, ManagedRuntime, type Effect } from "effect";
import { BackendRegistry, type SubagentBackend } from "./backend.ts";
import { claudeBackend } from "./backends/claude.ts";
import { codexBackend } from "./backends/codex.ts";
import { piBackend } from "./backends/pi.ts";
import type { BackendName } from "./domain.ts";
const BackendRegistryLive = Layer.sync(BackendRegistry, () => {
const backends: SubagentBackend[] = [piBackend, claudeBackend, codexBackend];
return new Map<BackendName, SubagentBackend>(
backends.map((backend) => [backend.name, backend]),
);
});
import { SubagentManagerLive } from "./manager.ts";
const AppLayer = SubagentManagerLive.pipe(Layer.provide(BackendRegistryLive));
export function createSubagentRuntime() {
return ManagedRuntime.make(AppLayer);
}
export type SubagentRuntime = ReturnType<typeof createSubagentRuntime>;
/**
* Run an effect from an async tool handler. Typed failures and defects are
* converted to thrown Errors (what pi's tool contract expects); interruption
* (tool AbortSignal) throws `interruptMessage`.
*/
export async function runTool<A, E>(
runtime: SubagentRuntime,
effect: Effect.Effect<A, E>,
options: { signal?: AbortSignal; interruptMessage?: string } = {},
) {
const exit = await runtime.runPromiseExit(
effect,
options.signal ? { signal: options.signal } : undefined,
);
if (Exit.isSuccess(exit)) return exit.value;
if (Cause.hasInterruptsOnly(exit.cause)) {
throw new Error(options.interruptMessage ?? "Operation was aborted.");
}
const [first] = Cause.prettyErrors(exit.cause);
throw new Error(first?.message ?? Cause.pretty(exit.cause));
}
+583
View File
@@ -0,0 +1,583 @@
/**
* Takeover UI for subagents (ported from v1, rendering from the synchronous
* SubagentReadModel instead of live pi sessions):
* - SubagentDashboard: full popup (overlay) listing all subagents.
* - TakeoverView: full interactive view of one subagent with an input line
* to steer/continue it.
*/
import type {
ExtensionCommandContext,
KeybindingsManager,
Theme,
} from "@earendil-works/pi-coding-agent";
import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { formatElapsed, type SubagentSnapshot } from "../domain.ts";
import { formatContextUtilization } from "../format.ts";
import type { SubagentReadModel } from "../manager.ts";
import { buildTranscriptLines } from "./transcript.ts";
function configuredKeys(
keybindings: KeybindingsManager,
binding: Parameters<KeybindingsManager["getKeys"]>[0],
) {
return keybindings.getKeys(binding).join("/") || "unbound";
}
function statusGlyph(snap: SubagentSnapshot, theme: Theme): string {
switch (snap.status) {
case "running":
return theme.fg("warning", "■");
case "done":
return theme.fg("success", "■");
case "error":
return theme.fg("error", "■");
}
}
function statusWord(snap: SubagentSnapshot, theme: Theme): string {
switch (snap.status) {
case "running":
return theme.fg("warning", "running");
case "done":
return theme.fg("success", "done");
case "error":
return theme.fg("error", "failed");
}
}
// --- Entry points --------------------------------------------------------------
export interface TakeoverOptions {
readonly badge?: string;
}
export async function openSubagentTakeover(
ctx: ExtensionCommandContext,
view: SubagentReadModel,
id: string,
options?: TakeoverOptions,
) {
if (!view.get(id)) return;
await ctx.ui.custom<null>(
(tui, theme, keybindings, done) =>
new TakeoverView(tui, theme, keybindings, id, view, done, options),
{
overlay: true,
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
},
);
}
export async function openSubagentPicker(
ctx: ExtensionCommandContext,
view: SubagentReadModel,
) {
const selection: DashboardSelection = { index: 0 };
while (true) {
if (view.size() === 0) {
ctx.ui.notify("No subagents", "info");
return;
}
const picked = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) =>
new SubagentDashboard(tui, theme, keybindings, view, selection, done),
{
overlay: true,
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
},
);
if (!picked) return;
if (!view.get(picked)) continue;
await openSubagentTakeover(ctx, view, picked);
// After leaving the takeover view, fall back to the dashboard.
}
}
// --- Dashboard (fullscreen overlay) ----------------------------------------------
export interface DashboardSelection {
id?: string;
index: number;
}
export function reconcileDashboardSelection(
selection: DashboardSelection,
subs: ReadonlyArray<Pick<SubagentSnapshot, "id">>,
) {
const stableIndex = selection.id
? subs.findIndex((snap) => snap.id === selection.id)
: -1;
selection.index =
stableIndex >= 0
? stableIndex
: Math.min(Math.max(0, selection.index), Math.max(0, subs.length - 1));
selection.id = subs[selection.index]?.id;
}
class SubagentDashboard implements Component {
private tui: TUI;
private theme: Theme;
private keybindings: KeybindingsManager;
private view: SubagentReadModel;
private selection: DashboardSelection;
private done: (value: string | null) => void;
private closed = false;
private ticker: ReturnType<typeof setInterval>;
private unsubChange: () => void;
constructor(
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
view: SubagentReadModel,
selection: DashboardSelection,
done: (value: string | null) => void,
) {
this.tui = tui;
this.theme = theme;
this.keybindings = keybindings;
this.view = view;
this.selection = selection;
this.done = done;
// Elapsed times, token counts, and statuses tick along at 1Hz.
this.ticker = setInterval(() => this.tui.requestRender(), 1000);
this.unsubChange = view.subscribe(() => this.tui.requestRender());
}
private subs(): ReadonlyArray<SubagentSnapshot> {
return this.view.list();
}
private cleanup() {
if (this.closed) return false;
this.closed = true;
clearInterval(this.ticker);
this.unsubChange();
return true;
}
private close(result: string | null) {
if (this.cleanup()) this.done(result);
}
dispose(): void {
this.cleanup();
}
handleInput(data: string): void {
const subs = this.subs();
reconcileDashboardSelection(this.selection, subs);
if (this.keybindings.matches(data, "tui.select.cancel")) {
this.close(null);
return;
}
if (this.keybindings.matches(data, "tui.select.confirm")) {
const snap = subs[this.selection.index];
if (snap) this.close(snap.id);
return;
}
if (this.keybindings.matches(data, "tui.select.up") || data === "k") {
if (subs.length > 0) {
this.selection.index =
(this.selection.index - 1 + subs.length) % subs.length;
this.selection.id = subs[this.selection.index]?.id;
this.tui.requestRender();
}
return;
}
if (this.keybindings.matches(data, "tui.select.down") || data === "j") {
if (subs.length > 0) {
this.selection.index = (this.selection.index + 1) % subs.length;
this.selection.id = subs[this.selection.index]?.id;
this.tui.requestRender();
}
return;
}
if (data === "x") {
const snap = subs[this.selection.index];
if (snap && snap.status === "running") this.view.requestAbort(snap.id);
return;
}
}
private pad(text: string, width: number): string {
const truncated = truncateToWidth(text, width);
return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
}
private borderSegment(width: number, title: string): string {
const theme = this.theme;
const label = title
? ` ${truncateToWidth(title, Math.max(0, width - 3))} `
: "";
const labelWidth = visibleWidth(label);
return (
theme.fg("border", "─") +
(label ? theme.fg("text", label) : "") +
theme.fg("border", "─".repeat(Math.max(0, width - 1 - labelWidth)))
);
}
render(width: number): string[] {
const theme = this.theme;
const subs = this.subs();
reconcileDashboardSelection(this.selection, subs);
const rows = this.tui.terminal.rows || 30;
// Render exactly terminal rows - 1 so the overlay covers the header,
// chat, editor, and extra footer lines while leaving pi's final footer
// row visible.
const bodyHeight = Math.max(6, rows - 5);
const innerWidth = width - 2;
const lines: string[] = [];
// Header: title left, count right
const headerLeft = theme.fg("accent", theme.bold("Subagents"));
const headerRight = theme.fg(
"muted",
`${subs.length} agent${subs.length === 1 ? "" : "s"}`,
);
const headerPad = Math.max(
1,
width - visibleWidth(headerLeft) - visibleWidth(headerRight) - 4,
);
lines.push(
truncateToWidth(
` ${headerLeft}${" ".repeat(headerPad)}${headerRight} `,
width,
),
);
// Top border with panel title
const settled = subs.filter((s) => s.status !== "running").length;
lines.push(
theme.fg("border", "╭") +
this.borderSegment(innerWidth, `agents · ${settled}/${subs.length}`) +
theme.fg("border", "╮"),
);
// Rows
const divider = theme.fg("border", "│");
const rowLines = this.renderRows(subs, innerWidth, bodyHeight);
for (let i = 0; i < bodyHeight; i++) {
lines.push(divider + this.pad(rowLines[i] ?? "", innerWidth) + divider);
}
// Bottom border
lines.push(
theme.fg("border", "╰") +
theme.fg("border", "─".repeat(innerWidth)) +
theme.fg("border", "╯"),
);
// Hints
lines.push(
truncateToWidth(
theme.fg(
"dim",
` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} take over · x abort · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`,
),
width,
),
);
return lines;
}
private renderRows(
subs: ReadonlyArray<SubagentSnapshot>,
width: number,
height: number,
): string[] {
const theme = this.theme;
const out: string[] = [];
// Scroll window around selection
let start = 0;
if (subs.length > height) {
start = Math.min(
Math.max(0, this.selection.index - Math.floor(height / 2)),
subs.length - height,
);
}
const visible = subs.slice(start, start + height);
for (let i = 0; i < visible.length; i++) {
const snap = visible[i];
const index = start + i;
const isSelected = index === this.selection.index;
// Left: marker, status square, title, dim id
const marker = isSelected ? theme.fg("accent", "❯") : " ";
const title = isSelected
? theme.fg("accent", snap.title)
: theme.fg("text", snap.title);
const left = ` ${marker} ${statusGlyph(snap, theme)} ${title} ${theme.fg("dim", snap.id)}`;
// Right: backend · model · context utilization · elapsed · status
const utilization = formatContextUtilization(snap.usage);
const dot = theme.fg("dim", " · ");
const rightParts = [
theme.fg("muted", snap.backend),
theme.fg("muted", snap.meta.modelLabel ?? "?"),
...(utilization ? [theme.fg("muted", utilization)] : []),
theme.fg("muted", formatElapsed(snap)),
statusWord(snap, theme),
];
const right = `${rightParts.join(dot)} `;
const rightWidth = visibleWidth(right);
const leftMax = Math.max(0, width - rightWidth - 2);
const leftTruncated = truncateToWidth(left, leftMax);
const gap = Math.max(2, width - visibleWidth(leftTruncated) - rightWidth);
out.push(truncateToWidth(leftTruncated + " ".repeat(gap) + right, width));
}
if (start > 0) {
out[0] = truncateToWidth(theme.fg("dim", ` ... ${start} more`), width);
}
if (start + height < subs.length) {
out[out.length - 1] = truncateToWidth(
theme.fg("dim", ` ... ${subs.length - start - height} more`),
width,
);
}
return out;
}
invalidate(): void {}
}
// --- Takeover view ------------------------------------------------------------
const TRANSCRIPT_SCROLL_STEP = 6;
class TakeoverView implements Component, Focusable {
private tui: TUI;
private theme: Theme;
private keybindings: KeybindingsManager;
private id: string;
private view: SubagentReadModel;
private done: (value: null) => void;
private options?: TakeoverOptions;
private input = new Input();
/** Scroll offset in lines from the bottom of the transcript. 0 = pinned to bottom. */
private scrollOffset = 0;
private unsubscribe: () => void;
private renderTimer?: ReturnType<typeof setTimeout>;
private ticker: ReturnType<typeof setInterval>;
private closed = false;
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}
constructor(
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
id: string,
view: SubagentReadModel,
done: (value: null) => void,
options?: TakeoverOptions,
) {
this.tui = tui;
this.theme = theme;
this.keybindings = keybindings;
this.id = id;
this.view = view;
this.done = done;
this.options = options;
this.unsubscribe = view.subscribeTo(id, () => this.scheduleRender());
// Elapsed time in the header ticks along at 1Hz.
this.ticker = setInterval(() => this.tui.requestRender(), 1000);
this.input.onSubmit = (value: string) => {
const text = value.trim();
if (!text) return;
this.input.setValue("");
this.view.requestSend(this.id, text);
this.scrollOffset = 0;
this.tui.requestRender();
};
}
private snap(): SubagentSnapshot | undefined {
return this.view.get(this.id);
}
private scheduleRender() {
if (this.renderTimer) return;
// Streaming can emit an event per token. Limit terminal repaints so this
// view cannot starve input handling or make the child look frozen.
this.renderTimer = setTimeout(() => {
this.renderTimer = undefined;
if (!this.closed) this.tui.requestRender();
}, 50);
}
private cleanup() {
if (this.closed) return false;
this.closed = true;
this.unsubscribe();
clearInterval(this.ticker);
if (this.renderTimer) clearTimeout(this.renderTimer);
this.renderTimer = undefined;
return true;
}
private close() {
if (this.cleanup()) this.done(null);
}
dispose(): void {
this.cleanup();
}
handleInput(data: string): void {
if (this.keybindings.matches(data, "app.clear")) {
const snap = this.snap();
if (snap?.status === "running") this.view.requestAbort(this.id);
return;
}
if (
this.keybindings.matches(data, "app.interrupt") ||
this.keybindings.matches(data, "tui.select.cancel")
) {
this.close();
return;
}
if (this.keybindings.matches(data, "tui.editor.cursorUp")) {
this.scrollOffset += TRANSCRIPT_SCROLL_STEP;
this.tui.requestRender();
return;
}
if (this.keybindings.matches(data, "tui.editor.cursorDown")) {
this.scrollOffset = Math.max(
0,
this.scrollOffset - TRANSCRIPT_SCROLL_STEP,
);
this.tui.requestRender();
return;
}
if (this.keybindings.matches(data, "tui.editor.pageUp")) {
this.scrollOffset += this.viewportHeight();
this.tui.requestRender();
return;
}
if (this.keybindings.matches(data, "tui.editor.pageDown")) {
this.scrollOffset = Math.max(
0,
this.scrollOffset - this.viewportHeight(),
);
this.tui.requestRender();
return;
}
this.input.handleInput(data);
this.tui.requestRender();
}
private viewportHeight(): number {
const rows = this.tui.terminal.rows || 30;
// The complete view renders viewport + 7 chrome rows. Using rows - 8
// makes the overlay exactly terminal rows - 1.
return Math.max(6, rows - 8);
}
render(width: number): string[] {
const theme = this.theme;
const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
const lines: string[] = [];
const snap = this.snap();
if (!snap) {
lines.push(border);
lines.push(theme.fg("dim", `${this.id} is no longer tracked`));
lines.push(border);
return lines;
}
lines.push(border);
const utilization = formatContextUtilization(snap.usage);
const header =
`${statusGlyph(snap, theme)} ` +
theme.fg("accent", theme.bold(`${snap.id} · ${snap.title}`)) +
theme.fg("muted", ` · ${snap.status} · ${formatElapsed(snap)}`) +
(this.options?.badge
? theme.fg("muted", ` · ${this.options.badge}`)
: "") +
theme.fg("dim", ` · ${snap.backend}: ${snap.meta.modelLabel ?? "?"}`) +
(utilization ? theme.fg("dim", ` · ${utilization}`) : "");
lines.push(truncateToWidth(header, width));
lines.push(border);
// Fixed-height transcript viewport. Error and scroll status consume rows
// inside the viewport so streaming/scrolling never changes overlay height.
const transcript = buildTranscriptLines(snap, width, theme);
const viewport = this.viewportHeight();
const errorRows = snap.errorText ? 1 : 0;
const scrollRows = this.scrollOffset > 0 ? 1 : 0;
const transcriptCapacity = Math.max(1, viewport - errorRows - scrollRows);
const maxOffset = Math.max(0, transcript.length - transcriptCapacity);
if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset;
const body: string[] = [];
if (snap.errorText) {
body.push(
truncateToWidth(theme.fg("error", `error: ${snap.errorText}`), width),
);
}
const capacity = Math.max(
1,
viewport - body.length - (this.scrollOffset > 0 ? 1 : 0),
);
const end = transcript.length - this.scrollOffset;
const visible = transcript.slice(Math.max(0, end - capacity), end);
if (visible.length === 0) body.push(theme.fg("dim", "(no output yet)"));
else body.push(...visible);
if (this.scrollOffset > 0) {
body.push(
truncateToWidth(
theme.fg("dim", `... ${this.scrollOffset} lines below · ↓/pgdn`),
width,
),
);
}
while (body.length < viewport) body.push("");
lines.push(...body.slice(0, viewport));
lines.push(border);
lines.push(...this.input.render(width));
lines.push(
truncateToWidth(
theme.fg(
"dim",
`${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`,
),
width,
),
);
lines.push(border);
return lines;
}
invalidate(): void {
this.input.invalidate();
}
}
+182
View File
@@ -0,0 +1,182 @@
/**
* Transcript rendering for the takeover view: turns a SubagentSnapshot's
* normalized transcript + live state into plain wrapped lines. Ported from
* v1, with the session-poking replaced by snapshot reads.
*/
import type { Theme } from "@earendil-works/pi-coding-agent";
import {
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import type { SubagentSnapshot, TranscriptItem } from "../domain.ts";
const ANSI_PATTERN =
// eslint-disable-next-line no-control-regex
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
/**
* Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded
* tabs (and stray escapes) make lines wider than the width we declare to the
* TUI, which desyncs the renderer and smears the overlay.
*/
export function sanitizeText(text: string): string {
return text
.replace(ANSI_PATTERN, "")
.replaceAll("\t", " ")
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
}
function renderUserText(
theme: Theme,
text: string,
width: number,
out: string[],
) {
const clean = sanitizeText(text).trim();
if (!clean) return;
const wrapped = wrapTextWithAnsi(clean, Math.max(10, width - 2));
for (let i = 0; i < wrapped.length; i++) {
const prefix = i === 0 ? theme.fg("accent", "> ") : " ";
out.push(
truncateToWidth(prefix + theme.fg("userMessageText", wrapped[i]), width),
);
}
}
function renderThinking(
theme: Theme,
text: string,
width: number,
out: string[],
) {
const reasoning = sanitizeText(text).trim();
if (!reasoning) return;
const prefix = theme.fg("dim", "~ ");
const wrapped = wrapTextWithAnsi(reasoning, Math.max(10, width - 2));
for (let i = 0; i < wrapped.length; i++) {
out.push(
truncateToWidth(
(i === 0 ? prefix : " ") + theme.fg("muted", theme.italic(wrapped[i])),
width,
),
);
}
}
function renderAssistantItem(
theme: Theme,
item: Extract<TranscriptItem, { kind: "assistant" }>,
width: number,
out: string[],
) {
for (const part of item.parts) {
if (part.type === "text") {
const text = sanitizeText(part.text).trim();
if (!text) continue;
out.push(...wrapTextWithAnsi(text, width));
} else if (part.type === "thinking") {
renderThinking(
theme,
part.redacted ? "[redacted reasoning]" : part.text,
width,
out,
);
} else if (part.type === "toolCall") {
const preview = part.argsPreview ? sanitizeText(part.argsPreview) : "";
const line =
theme.fg("muted", "→ ") +
theme.fg("toolTitle", part.name) +
(preview && preview !== "{}" ? theme.fg("dim", ` ${preview}`) : "");
out.push(truncateToWidth(line, width));
}
}
}
function renderToolResultItem(
theme: Theme,
item: Extract<TranscriptItem, { kind: "toolResult" }>,
width: number,
out: string[],
) {
const firstLine =
sanitizeText(item.outputPreview ?? "")
.split("\n")
.find((line) => line.trim()) ?? "";
const label = item.isError
? theme.fg("error", " error: ")
: theme.fg("dim", " output: ");
out.push(
truncateToWidth(label + theme.fg("dim", firstLine || "(no output)"), width),
);
}
/** Render a subagent's conversation as plain lines, wrapped to `width`. */
export function buildTranscriptLines(
snap: SubagentSnapshot,
width: number,
theme: Theme,
): string[] {
const out: string[] = [];
for (const item of snap.transcript) {
const before = out.length;
if (item.kind === "user") {
renderUserText(theme, item.text, width, out);
} else if (item.kind === "assistant") {
renderAssistantItem(theme, item, width, out);
} else {
renderToolResultItem(theme, item, width, out);
}
if (out.length > before) out.push("");
}
while (out.length > 0 && out[out.length - 1] === "") out.pop();
// Live streaming assistant buffers (cleared when the finalized message lands).
if (snap.liveAssistant) {
const { thinking, text } = snap.liveAssistant;
const before = out.length;
if (out.length > 0) out.push("");
if (thinking.trim()) renderThinking(theme, thinking, width, out);
if (text.trim())
out.push(...wrapTextWithAnsi(sanitizeText(text).trim(), width));
if (out.length === before + 1) out.pop();
}
// Live tool executions (present until the ToolEnd lands in the transcript).
for (const tool of snap.liveTools) {
if (out.length > 0) out.push("");
const marker = tool.done
? tool.isError
? theme.fg("error", "error")
: theme.fg("success", "done")
: theme.fg("warning", "running");
let line = `${theme.fg("toolTitle", tool.name)} · ${marker}`;
const preview = tool.outputPreview && sanitizeText(tool.outputPreview);
if (preview) line += theme.fg("dim", ` · ${preview}`);
out.push(truncateToWidth(line, width));
}
// Queued steering/follow-up messages: show them immediately so Enter
// visibly acknowledges the user's input instead of appearing to do nothing.
for (const message of snap.queued) {
if (out.length > 0) out.push("");
const prefix = theme.fg("warning", `> [queued ${message.kind}] `);
const wrapped = wrapTextWithAnsi(
sanitizeText(message.text),
Math.max(10, width - visibleWidth(prefix)),
);
for (let i = 0; i < wrapped.length; i++) {
out.push(
truncateToWidth(
(i === 0 ? prefix : " ".repeat(visibleWidth(prefix))) +
theme.fg("muted", wrapped[i]),
width,
),
);
}
}
return out;
}
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
reconcileDashboardSelection,
type DashboardSelection,
} from "./src/ui/takeover.ts";
test("dashboard selection follows its subagent id and falls back by row", () => {
const selection: DashboardSelection = { id: "sa-7", index: 6 };
reconcileDashboardSelection(selection, [
{ id: "sa-new" },
...Array.from({ length: 8 }, (_, index) => ({ id: `sa-${index + 1}` })),
]);
assert.deepEqual(selection, { id: "sa-7", index: 7 });
reconcileDashboardSelection(selection, [
...Array.from({ length: 6 }, (_, index) => ({ id: `sa-${index + 1}` })),
{ id: "sa-8" },
{ id: "sa-9" },
]);
assert.deepEqual(selection, { id: "sa-9", index: 7 });
reconcileDashboardSelection(selection, [{ id: "sa-1" }, { id: "sa-2" }]);
assert.deepEqual(selection, { id: "sa-2", index: 1 });
reconcileDashboardSelection(selection, []);
assert.deepEqual(selection, { id: undefined, index: 0 });
});
+145
View File
@@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import {
boundedArtifactTranscript,
createWorkflowPersistence,
persistWorkflowJson,
} from "./artifacts.ts";
import {
emptyUsage,
type TranscriptEntry,
type WorkflowDetails,
} from "./model.ts";
function workflowDetails(): WorkflowDetails {
return {
runId: "wf_fixture",
sessionId: "session_fixture",
background: false,
status: "running",
startedAt: 1,
phases: [],
agents: [],
};
}
test("artifact transcript keeps the initial prompt, marker, and newest entries", () => {
const prompt = `initial:${"p".repeat(70)}`;
const transcript = [
{ role: "user" as const, text: prompt },
...Array.from({ length: 5 }, (_, index) => ({
role: "assistant" as const,
text: `entry-${index}:${String(index).repeat(70)}`,
})),
];
const bounded = boundedArtifactTranscript(transcript, {
maxBytes: 256,
entryMaxBytes: 80,
});
assert.equal(bounded[0]?.role, "user");
assert.equal(bounded[0]?.text, prompt);
assert.match(bounded[1]?.text ?? "", /artifact transcript truncated/);
assert.equal(bounded.at(-1)?.text, transcript.at(-1)?.text);
assert.equal(
bounded.some((entry) => entry.text.startsWith("entry-0:")),
false,
);
assert.ok(
bounded.reduce(
(total, entry) => total + Buffer.byteLength(entry.text, "utf8"),
0,
) <= 256,
);
});
test("live artifact persistence includes current agents and transcripts", () => {
const directory = mkdtempSync(join(tmpdir(), "pi-workflow-artifacts-"));
try {
const details = workflowDetails();
details.agents.push({
index: 1,
label: "running-fixture",
state: "running",
startedAt: 2,
preview: "working",
usage: emptyUsage(),
transcript: [
{ role: "user", text: "current prompt" },
{
role: "tool",
name: "fixture",
toolCallId: "call-fixture",
text: "{}",
startedAt: 10,
finishedAt: 25,
durationMs: 15,
},
],
});
persistWorkflowJson(directory, details);
const workflow = JSON.parse(
readFileSync(join(directory, "workflow.json"), "utf8"),
) as WorkflowDetails;
const transcripts = JSON.parse(
readFileSync(join(directory, "transcripts.json"), "utf8"),
) as Record<string, TranscriptEntry[]>;
assert.equal(workflow.agents.length, 1);
assert.equal(workflow.agents[0]?.label, "running-fixture");
assert.equal(transcripts["1"]?.[0]?.text, "current prompt");
assert.deepEqual(
{
toolCallId: transcripts["1"]?.[1]?.toolCallId,
startedAt: transcripts["1"]?.[1]?.startedAt,
finishedAt: transcripts["1"]?.[1]?.finishedAt,
durationMs: transcripts["1"]?.[1]?.durationMs,
},
{
toolCallId: "call-fixture",
startedAt: 10,
finishedAt: 25,
durationMs: 15,
},
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("workflow checkpoints throttle updates and support immediate/final flushes", async () => {
const details = workflowDetails();
const snapshots: WorkflowDetails[] = [];
const persistence = createWorkflowPersistence("fixture", details, {
intervalMs: 15,
persist: (_runDir, current) => snapshots.push(structuredClone(current)),
});
details.currentPhase = "Scan";
persistence.checkpoint();
details.currentPhase = "Review";
persistence.checkpoint();
assert.equal(snapshots.length, 0);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(snapshots.length, 1);
assert.equal(snapshots[0]?.currentPhase, "Review");
details.status = "completed";
persistence.checkpoint({ immediate: true });
assert.equal(snapshots.length, 2);
assert.equal(snapshots[1]?.status, "completed");
details.finishedAt = 3;
persistence.flush();
assert.equal(snapshots.length, 3);
assert.equal(snapshots[2]?.finishedAt, 3);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(snapshots.length, 3);
});
+179
View File
@@ -0,0 +1,179 @@
import type { TranscriptEntry, WorkflowDetails } from "./model.ts";
import {
safeStringify,
truncateUtf8,
writeFileAtomic,
} from "./serialization.ts";
import * as path from "node:path";
const ARTIFACT_TRANSCRIPT_MAX_BYTES = 32 * 1024;
const ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES = 8 * 1024;
export const WORKFLOW_CHECKPOINT_INTERVAL_MS = 500;
const ENTRY_TRUNCATION_MARKER = "\n[entry truncated]";
const TRANSCRIPT_TRUNCATION_MARKER =
"[artifact transcript truncated: older entries omitted]";
function textBytes(text: string) {
return Buffer.byteLength(text, "utf8");
}
function boundEntry(entry: TranscriptEntry, maxBytes: number) {
if (textBytes(entry.text) <= maxBytes) return { ...entry };
const markerBytes = textBytes(ENTRY_TRUNCATION_MARKER);
const text =
maxBytes > markerBytes
? `${truncateUtf8(entry.text, maxBytes - markerBytes)}${ENTRY_TRUNCATION_MARKER}`
: truncateUtf8(ENTRY_TRUNCATION_MARKER, maxBytes);
return { ...entry, text };
}
/** Keep the initial prompt plus the newest useful context within the artifact cap. */
export function boundedArtifactTranscript(
transcript: TranscriptEntry[],
options: { maxBytes?: number; entryMaxBytes?: number } = {},
) {
if (transcript.length === 0) return [];
const maxBytes = Math.max(
256,
options.maxBytes ?? ARTIFACT_TRANSCRIPT_MAX_BYTES,
);
const entryMaxBytes = Math.max(
64,
Math.min(
maxBytes,
options.entryMaxBytes ?? ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES,
),
);
const bounded = transcript.map((entry) => boundEntry(entry, entryMaxBytes));
if (
bounded.reduce((total, entry) => total + textBytes(entry.text), 0) <=
maxBytes
) {
return bounded;
}
const initialIndex = transcript.findIndex((entry) => entry.role === "user");
const initial = boundEntry(
transcript[initialIndex >= 0 ? initialIndex : 0],
Math.min(entryMaxBytes, maxBytes - textBytes(TRANSCRIPT_TRUNCATION_MARKER)),
);
const marker: TranscriptEntry = {
role: "toolResult",
name: "transcript",
text: TRANSCRIPT_TRUNCATION_MARKER,
};
let remaining = maxBytes - textBytes(initial.text) - textBytes(marker.text);
const tail: TranscriptEntry[] = [];
for (
let index = transcript.length - 1;
index >= 0 && remaining > 0;
index--
) {
if (index === initialIndex || (initialIndex < 0 && index === 0)) continue;
const entry = boundEntry(
transcript[index],
Math.min(entryMaxBytes, remaining),
);
tail.push(entry);
remaining -= textBytes(entry.text);
}
tail.reverse();
return [initial, marker, ...tail];
}
function writeRunFile(runDir: string, name: string, content: string) {
writeFileAtomic(path.join(runDir, name), content);
}
export function persistWorkflowJson(runDir: string, details: WorkflowDetails) {
const transcripts = Object.fromEntries(
details.agents.map((agent) => [
agent.index,
boundedArtifactTranscript(agent.transcript),
]),
);
writeRunFile(
runDir,
"transcripts.json",
safeStringify(transcripts, { maxBytes: 2 * 1024 * 1024 }),
);
if (details.result !== undefined) {
writeRunFile(
runDir,
"result.json",
safeStringify(details.result, { maxBytes: 1024 * 1024 }),
);
}
const compact: WorkflowDetails = {
...details,
...(details.result !== undefined
? { result: "[stored in result.json]", resultArtifact: "result.json" }
: {}),
transcriptArtifact: "transcripts.json",
agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
};
writeRunFile(
runDir,
"workflow.json",
safeStringify(compact, { maxBytes: 1024 * 1024 }),
);
}
/** Coalesce live checkpoints while keeping final persistence synchronous. */
export function createWorkflowPersistence(
runDir: string,
details: WorkflowDetails,
options: {
intervalMs?: number;
persist?: (runDir: string, details: WorkflowDetails) => void;
} = {},
) {
const intervalMs = Math.max(
0,
options.intervalMs ?? WORKFLOW_CHECKPOINT_INTERVAL_MS,
);
const persist = options.persist ?? persistWorkflowJson;
let lastPersistedAt = Date.now();
let dirty = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const savePending = () => {
timer = undefined;
if (!dirty) return;
try {
persist(runDir, details);
dirty = false;
lastPersistedAt = Date.now();
} catch {
// Final flush retries and reports persistence failures synchronously.
}
};
return {
checkpoint(options: { immediate?: boolean } = {}) {
dirty = true;
if (options.immediate) {
if (timer) clearTimeout(timer);
timer = undefined;
savePending();
return;
}
if (timer) return;
const delay = Math.max(0, intervalMs - (Date.now() - lastPersistedAt));
if (delay === 0) {
savePending();
return;
}
timer = setTimeout(savePending, delay);
},
flush() {
if (timer) clearTimeout(timer);
timer = undefined;
persist(runDir, details);
dirty = false;
lastPersistedAt = Date.now();
},
};
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { MAX_AGENT_CALLS, RunController } from "./controller.ts";
const delay = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
test("RunController reserves calls synchronously and caps global fanout", async () => {
const controller = new RunController(undefined, 4);
let active = 0;
let peak = 0;
const tasks = Array.from({ length: 12 }, (_, index) =>
controller.schedule(async () => {
active++;
peak = Math.max(peak, active);
await delay(5);
active--;
return index;
}),
);
assert.deepEqual(
await Promise.all(tasks),
Array.from({ length: 12 }, (_, i) => i),
);
assert.equal(peak, 4);
assert.equal(await controller.settle(), true);
});
test("RunController propagates invocation cancellation without aborting the run", async () => {
const controller = new RunController(undefined, 1);
const invocation = new AbortController();
const pending = controller.schedule(
(signal) =>
new Promise<string>((resolve) => {
signal.addEventListener("abort", () => resolve("stopped"), {
once: true,
});
}),
invocation.signal,
);
invocation.abort(new Error("Workflow agent request was cancelled"));
await assert.rejects(pending, /request was cancelled/);
assert.equal(controller.signal.aborted, false);
assert.equal(await controller.schedule(async () => "recovered"), "recovered");
assert.equal(await controller.settle(), true);
});
test("RunController enforces call budget and aborts queued tasks", async () => {
const controller = new RunController(undefined, 1);
const blocker = controller.schedule(
(signal) =>
new Promise<void>((resolve) =>
signal.addEventListener("abort", () => resolve(), { once: true }),
),
);
const queued = Array.from({ length: MAX_AGENT_CALLS - 1 }, () =>
controller.schedule(async () => "queued"),
);
await assert.rejects(
controller.schedule(async () => "too many"),
/exceeded the limit/,
);
controller.abort();
await blocker;
const results = await Promise.allSettled(queued);
assert.ok(results.every((result) => result.status === "rejected"));
assert.equal(await controller.settle({ abort: true }), true);
});
+192
View File
@@ -0,0 +1,192 @@
const DEFAULT_CONCURRENCY = 4;
export const MAX_AGENT_CALLS = 32;
export const RUN_SHUTDOWN_TIMEOUT_MS = 8_000;
function abortError(signal: AbortSignal) {
return signal.reason instanceof Error
? signal.reason
: new Error("Workflow was aborted");
}
class Semaphore {
private active = 0;
private readonly limit: number;
private queue: Array<{
resolve: () => void;
reject: (error: Error) => void;
signal: AbortSignal;
onAbort: () => void;
}> = [];
constructor(limit: number) {
this.limit = limit;
}
acquire(signal: AbortSignal) {
if (signal.aborted) return Promise.reject(abortError(signal));
if (this.active < this.limit) {
this.active++;
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const waiter = {
resolve: () => {
signal.removeEventListener("abort", onAbort);
this.active++;
resolve();
},
reject,
signal,
onAbort: () => {},
};
const onAbort = () => {
const index = this.queue.indexOf(waiter);
if (index >= 0) this.queue.splice(index, 1);
reject(abortError(signal));
};
waiter.onAbort = onAbort;
this.queue.push(waiter);
signal.addEventListener("abort", onAbort, { once: true });
});
}
release() {
this.active = Math.max(0, this.active - 1);
while (this.queue.length > 0) {
const waiter = this.queue.shift()!;
if (waiter.signal.aborted) {
waiter.signal.removeEventListener("abort", waiter.onAbort);
waiter.reject(abortError(waiter.signal));
continue;
}
waiter.resolve();
return;
}
}
clear() {
const queued = this.queue;
this.queue = [];
for (const waiter of queued) {
waiter.signal.removeEventListener("abort", waiter.onAbort);
waiter.reject(abortError(waiter.signal));
}
}
}
/** Owns every agent task and the run-wide fanout/abort budget. */
export class RunController {
private readonly abortController = new AbortController();
private readonly semaphore: Semaphore;
private readonly tasks = new Set<Promise<unknown>>();
private callCount = 0;
private sealed = false;
private parentAbort?: () => void;
private parentSignal?: AbortSignal;
constructor(parentSignal?: AbortSignal, concurrency = DEFAULT_CONCURRENCY) {
this.semaphore = new Semaphore(
Math.max(1, Math.min(DEFAULT_CONCURRENCY, Math.floor(concurrency))),
);
if (parentSignal) {
this.parentSignal = parentSignal;
this.parentAbort = () => this.abort("Parent operation was aborted");
if (parentSignal.aborted) this.parentAbort();
else
parentSignal.addEventListener("abort", this.parentAbort, {
once: true,
});
}
}
get signal() {
return this.abortController.signal;
}
get calls() {
return this.callCount;
}
schedule<T>(
task: (signal: AbortSignal) => Promise<T>,
invocationSignal?: AbortSignal,
): Promise<T> {
if (this.sealed) return Promise.reject(new Error("Workflow is settling"));
if (this.signal.aborted) return Promise.reject(abortError(this.signal));
if (this.callCount >= MAX_AGENT_CALLS) {
return Promise.reject(
new Error(
`Workflow exceeded the limit of ${MAX_AGENT_CALLS} agent calls`,
),
);
}
this.callCount++;
const running = (async () => {
const taskAbort = new AbortController();
const onRunAbort = () => taskAbort.abort(this.signal.reason);
const onInvocationAbort = () => taskAbort.abort(invocationSignal?.reason);
this.signal.addEventListener("abort", onRunAbort, { once: true });
invocationSignal?.addEventListener("abort", onInvocationAbort, {
once: true,
});
if (this.signal.aborted) onRunAbort();
else if (invocationSignal?.aborted) onInvocationAbort();
let acquired = false;
try {
await this.semaphore.acquire(taskAbort.signal);
acquired = true;
if (taskAbort.signal.aborted) throw abortError(taskAbort.signal);
const result = await task(taskAbort.signal);
if (invocationSignal?.aborted) throw abortError(invocationSignal);
return result;
} finally {
this.signal.removeEventListener("abort", onRunAbort);
invocationSignal?.removeEventListener("abort", onInvocationAbort);
if (acquired) this.semaphore.release();
}
})();
this.tasks.add(running);
void running.finally(() => this.tasks.delete(running)).catch(() => {});
return running;
}
abort(reason = "Workflow was aborted") {
if (!this.signal.aborted) this.abortController.abort(new Error(reason));
this.semaphore.clear();
}
/** Seal the task registry and wait a bounded time for every task to settle. */
async settle(options: { abort?: boolean; timeoutMs?: number } = {}) {
this.sealed = true;
if (options.abort) this.abort();
const tasks = [...this.tasks];
if (tasks.length === 0) {
this.detachParent();
return true;
}
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<false>((resolve) => {
timer = setTimeout(
() => resolve(false),
options.timeoutMs ?? RUN_SHUTDOWN_TIMEOUT_MS,
);
timer.unref?.();
});
const settled = Promise.allSettled(tasks).then(() => true as const);
const completed = await Promise.race([settled, timeout]);
if (timer) clearTimeout(timer);
this.detachParent();
return completed;
}
private detachParent() {
if (this.parentAbort) {
this.parentSignal?.removeEventListener("abort", this.parentAbort);
}
this.parentAbort = undefined;
this.parentSignal = undefined;
}
}
File diff suppressed because it is too large Load Diff
+871
View File
@@ -0,0 +1,871 @@
/**
* workflows: model-authored multi-agent orchestration.
*
* A `workflow` tool that runs a JavaScript orchestration script written inline
* by the model. The script executes ordered phases, fanning work out to
* isolated subagents:
*
* export const meta = { name, description, phases: [{ title, detail? }] }
* phase(title) // mark runtime phase progression
* await agent(prompt, { label?, phase?, schema?, model?, provider?, effort? })
* await parallel([() => agent(...), ...], { concurrency? })
* args // parsed JSON args passed with the tool call
*
* `agent()` always resolves to `{ ok, output, structured?, error? }` — it
* never throws into the script. Scripts branch on `ok` explicitly.
*
* Runs are blocking by default (live progress in the tool block). Pass
* `background: true` to return immediately and get a follow-up message when
* the run finishes. Run artifacts (script, args, statuses, result) are saved
* under `~/.pi/agent/workflows/<runId>/` for inspection; result and bounded
* transcripts use separate artifacts, and there is no resume.
*/
import { randomBytes } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import {
getAgentDir,
getMarkdownTheme,
keyHint,
type ExtensionAPI,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { Type, type Static } from "typebox";
import { formatActivityStatus } from "../shared/activity-status.ts";
import { createWorkflowPersistence, persistWorkflowJson } from "./artifacts.ts";
import { RunController } from "./controller.ts";
import { sessionWorkflowRunIds, showWorkflowDashboard } from "./dashboard.ts";
import {
extractMeta,
prepareWorkflowScript,
type WorkflowMeta,
} from "./meta.ts";
import {
agentContext,
aggregateUsage,
countStates,
emptyUsage,
formatElapsed,
formatUsage,
phaseGroups,
resultJson,
stateSquare,
statusColor,
statusWord,
SQUARE,
type AgentRecord,
type WorkflowDetails,
} from "./model.ts";
import {
buildBackgroundWorkflowFollowUp,
buildBackgroundWorkflowLaunchResult,
buildWorkflowAgentPrompt,
buildWorkflowResultMessage,
WORKFLOW_PARAMETER_DESCRIPTIONS,
WORKFLOW_PROMPT_GUIDELINES,
WORKFLOW_PROMPT_SNIPPET,
WORKFLOW_TOOL_DESCRIPTION,
} from "./prompt.ts";
import {
createWorkflowResources,
runAgent,
type ThinkingLevel,
type WorkflowModel,
} from "./runner.ts";
import { runWorkflowSandbox } from "./sandbox.ts";
import { safeStringify, writeFileAtomic } from "./serialization.ts";
const PREVIEW_LENGTH = 200;
const EMIT_INTERVAL_MS = 120;
const THINKING_LEVELS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
/** What `agent()` resolves to inside the script. */
interface ScriptAgentResult {
ok: boolean;
output: string;
structured?: unknown;
error?: string;
}
interface AgentCallOptions {
label?: unknown;
phase?: unknown;
schema?: unknown;
model?: unknown;
provider?: unknown;
effort?: unknown;
}
const WorkflowParams = Type.Object({
script: Type.String({
description: WORKFLOW_PARAMETER_DESCRIPTIONS.script,
}),
args: Type.Optional(
Type.String({
description: WORKFLOW_PARAMETER_DESCRIPTIONS.args,
}),
),
background: Type.Optional(
Type.Boolean({
description: WORKFLOW_PARAMETER_DESCRIPTIONS.background,
}),
),
});
type WorkflowInput = Static<typeof WorkflowParams>;
function errorText(error: unknown): string {
return (error instanceof Error ? error.message : String(error)).slice(
0,
16 * 1024,
);
}
function summaryLine(details: WorkflowDetails): string {
const { done, failed } = countStates(details);
const settled = done + failed;
return `workflow ${details.name ?? details.runId}: ${settled}/${details.agents.length} agents${
details.currentPhase ? ` · ${details.currentPhase}` : ""
}`;
}
function writeRunFile(runDir: string, name: string, content: string) {
writeFileAtomic(path.join(runDir, name), content);
}
function compactToolDetails(details: WorkflowDetails): WorkflowDetails {
return {
...details,
...(details.result !== undefined
? {
result: JSON.parse(
safeStringify(details.result, { maxBytes: 64 * 1024 }),
),
}
: {}),
agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
};
}
interface RunSummary {
runId: string;
name?: string;
status: string;
done: number;
total: number;
startedAt: number;
active: boolean;
}
function listRuns(
activeRuns: Map<string, WorkflowDetails>,
sessionId: string,
referencedRunIds: ReadonlySet<string>,
): RunSummary[] {
const base = path.join(getAgentDir(), "workflows");
let names: string[] = [];
try {
names = fs.readdirSync(base).filter((name) => name.startsWith("wf_"));
} catch {
// No runs yet.
}
const summaries: RunSummary[] = [];
for (const runId of names) {
const live = activeRuns.get(runId);
if (live) {
const { done, failed } = countStates(live);
summaries.push({
runId,
name: live.name,
status: live.status,
done: done + failed,
total: live.agents.length,
startedAt: live.startedAt,
active: true,
});
continue;
}
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(base, runId, "workflow.json"), "utf8"),
) as Partial<WorkflowDetails>;
if (parsed.sessionId !== sessionId && !referencedRunIds.has(runId)) {
continue;
}
const agents = parsed.agents ?? [];
summaries.push({
runId,
name: parsed.name,
status:
parsed.status === "running"
? "aborted"
: (parsed.status ?? "unknown"),
done: agents.filter((agent) => agent.state !== "running").length,
total: agents.length,
startedAt: parsed.startedAt ?? 0,
active: false,
});
} catch {
// Ignore unreadable artifacts because their session cannot be verified.
}
}
return summaries.sort((a, b) => b.startedAt - a.startedAt);
}
function runDetailText(
run: RunSummary,
activeRuns: Map<string, WorkflowDetails>,
): string {
const runDir = path.join(getAgentDir(), "workflows", run.runId);
const live = activeRuns.get(run.runId);
if (live) return buildWorkflowResultMessage(live, runDir);
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(runDir, "workflow.json"), "utf8"),
) as WorkflowDetails;
return buildWorkflowResultMessage(parsed, runDir);
} catch {
return `Run ${run.runId} — ${run.status}`;
}
}
export default function workflows(pi: ExtensionAPI) {
/** Live background runs, for /workflows and shutdown cleanup. */
const activeRuns = new Map<
string,
{
details: WorkflowDetails;
controller: RunController;
completion?: Promise<void>;
}
>();
const activeDetails = () =>
new Map(
[...activeRuns].map(([runId, run]) => [runId, run.details] as const),
);
/** Finished counts remain visible until the dashboard acknowledges them. */
let lastUi: ExtensionContext["ui"] | undefined;
let completedRuns = 0;
let failedRuns = 0;
const updateIndicator = () => {
const ui = lastUi;
if (!ui) return;
try {
const running = activeRuns.size;
if (running === 0 && completedRuns === 0 && failedRuns === 0) {
ui.setStatus("workflows", undefined);
return;
}
ui.setStatus(
"workflows",
formatActivityStatus(ui.theme, "workflows", {
running,
done: completedRuns,
failed: failedRuns,
}),
);
} catch {
// UI may be unavailable.
}
};
const recordSettledRun = (status: WorkflowDetails["status"]) => {
if (status === "completed") completedRuns += 1;
else failedRuns += 1;
};
pi.on("session_start", (_event, ctx) => {
if (ctx.hasUI) lastUi = ctx.ui;
updateIndicator();
});
pi.on("session_shutdown", async () => {
const runs = [...activeRuns.values()];
for (const run of runs) run.controller.abort("Session is shutting down");
await Promise.all(
runs.map((run) => run.controller.settle({ abort: true })),
);
const completions = runs
.map((run) => run.completion)
.filter(
(completion): completion is Promise<void> => completion !== undefined,
);
if (completions.length > 0) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, 8_000);
timer.unref?.();
});
await Promise.race([Promise.allSettled(completions), timeout]);
if (timer) clearTimeout(timer);
}
lastUi?.setStatus("workflows", undefined);
lastUi = undefined;
});
pi.registerCommand("workflows", {
description:
"List workflow runs (`/workflows <runId>` for one run's detail)",
handler: async (rawArgs, ctx) => {
const arg = rawArgs.trim();
if (ctx.mode === "tui") {
lastUi = ctx.ui;
await showWorkflowDashboard(ctx, activeDetails, arg || undefined);
// Opening the dashboard acknowledges finished runs.
completedRuns = 0;
failedRuns = 0;
updateIndicator();
return;
}
// Non-TUI fallback: plain text listing.
const runs = listRuns(
activeDetails(),
ctx.sessionManager.getSessionId(),
sessionWorkflowRunIds(ctx),
);
if (runs.length === 0) {
ctx.ui.notify("No workflow runs yet.", "info");
return;
}
if (arg) {
const run = runs.find((r) => r.runId === arg || r.runId.endsWith(arg));
ctx.ui.notify(
run
? runDetailText(run, activeDetails())
: `No workflow run matching "${arg}".`,
run ? "info" : "warning",
);
return;
}
const labels = runs.map(
(r) =>
`${r.active ? "* " : " "}${r.runId} ${r.status} ${r.name ?? ""} ${r.done}/${r.total}`,
);
if (!ctx.hasUI) {
ctx.ui.notify(labels.join("\n"), "info");
return;
}
const choice = await ctx.ui.select("Workflow runs", labels);
if (!choice) return;
const run = runs[labels.indexOf(choice)];
if (run) ctx.ui.notify(runDetailText(run, activeDetails()), "info");
},
});
pi.registerTool({
name: "workflow",
label: "Workflow",
description: WORKFLOW_TOOL_DESCRIPTION,
promptSnippet: WORKFLOW_PROMPT_SNIPPET,
promptGuidelines: WORKFLOW_PROMPT_GUIDELINES,
parameters: WorkflowParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
let prepared: ReturnType<typeof prepareWorkflowScript>;
try {
prepared = prepareWorkflowScript(params.script);
} catch (error) {
throw new Error(`Workflow script failed to parse: ${errorText(error)}`);
}
let args: unknown;
if (params.args !== undefined) {
try {
args = JSON.parse(params.args);
} catch {
args = params.args;
}
}
const meta = prepared.meta;
const runId = `wf_${randomBytes(6).toString("hex")}`;
const runDir = path.join(getAgentDir(), "workflows", runId);
const background = (params.background ?? false) && ctx.hasUI;
const details: WorkflowDetails = {
runId,
sessionId: ctx.sessionManager.getSessionId(),
name: meta.name,
description: meta.description,
background,
status: "running",
startedAt: Date.now(),
phases: [...meta.phases],
agents: [],
};
writeRunFile(runDir, "script.js", params.script);
if (params.args !== undefined)
writeRunFile(runDir, "args.json", params.args);
persistWorkflowJson(runDir, details);
const persistence = createWorkflowPersistence(runDir, details);
// Background runs survive Esc on the parent turn, but all runs are
// aborted and settled during session shutdown.
const controller = new RunController(background ? undefined : signal);
// Each concurrent child gets its own extension runtime. All children use
// the parent cwd and live trust decision.
const projectTrusted = ctx.isProjectTrusted();
const getResources = (structured: boolean) =>
createWorkflowResources(
ctx.cwd,
structured ? "structured" : "plain",
projectTrusted,
);
// Throttled progress: tool-block updates when blocking. Background
// runs are covered by the below-editor indicator and /workflows.
let emitTimer: ReturnType<typeof setTimeout> | undefined;
let lastEmit = 0;
const flush = () => {
emitTimer = undefined;
lastEmit = Date.now();
if (background) return;
onUpdate?.({
content: [{ type: "text", text: summaryLine(details) }],
details: compactToolDetails(details),
});
};
const emit = (checkpoint = true) => {
if (checkpoint) persistence.checkpoint();
if (emitTimer) return;
emitTimer = setTimeout(
flush,
Math.max(0, EMIT_INTERVAL_MS - (Date.now() - lastEmit)),
);
};
const flushNow = () => {
if (emitTimer) clearTimeout(emitTimer);
flush();
};
const phaseFn = (title: unknown) => {
const text = String(title);
details.currentPhase = text;
if (!details.phases.some((p) => p.title === text))
details.phases.push({ title: text });
emit();
};
let agentCounter = 0;
const agentFn = async (
promptValue: unknown,
optsValue: unknown = {},
invocationSignal?: AbortSignal,
): Promise<ScriptAgentResult> => {
const index = ++agentCounter;
const opts: AgentCallOptions =
optsValue && typeof optsValue === "object"
? (optsValue as AgentCallOptions)
: {};
const label =
typeof opts.label === "string" && opts.label.trim()
? opts.label.trim().slice(0, 160)
: `agent-${index}`;
const record: AgentRecord = {
index,
label,
phase:
typeof opts.phase === "string"
? opts.phase.slice(0, 160)
: details.currentPhase,
state: "running",
model: ctx.model?.id,
contextWindow: ctx.model?.contextWindow,
startedAt: Date.now(),
preview: "",
usage: emptyUsage(),
transcript: [],
};
details.agents.push(record);
persistence.checkpoint({ immediate: true });
emit(false);
const fail = (error: string): ScriptAgentResult => {
record.state = "error";
record.error = error;
record.finishedAt = Date.now();
emit();
return { ok: false, output: "", error };
};
const prompt = buildWorkflowAgentPrompt(
typeof promptValue === "string"
? promptValue
: String(promptValue ?? ""),
);
if (!prompt.trim())
return fail("agent() requires a non-empty prompt string");
if (controller.signal.aborted)
return fail("Workflow was aborted before this agent started");
return controller
.schedule(async (runSignal) => {
// Model/provider resolution: default to the parent session's model.
let model: WorkflowModel | undefined = ctx.model;
if (opts.model !== undefined || opts.provider !== undefined) {
const modelOpt =
typeof opts.model === "string" ? opts.model : undefined;
const providerOpt =
typeof opts.provider === "string" ? opts.provider : undefined;
if (!modelOpt)
return fail(
`agent "${label}": \`provider\` requires \`model\` as well`,
);
let resolved: WorkflowModel | undefined;
if (providerOpt) {
resolved = ctx.modelRegistry.find(providerOpt, modelOpt);
} else {
const slash = modelOpt.indexOf("/");
if (slash > 0) {
resolved = ctx.modelRegistry.find(
modelOpt.slice(0, slash),
modelOpt.slice(slash + 1),
);
}
resolved ??= ctx.modelRegistry
.getAll()
.find((m) => m.id === modelOpt);
}
if (!resolved) {
const requested = providerOpt
? `${providerOpt}/${modelOpt}`
: modelOpt;
return fail(
`agent "${label}": unknown model "${requested}" (use provider/id)`,
);
}
model = resolved;
}
record.model = model?.id;
record.contextWindow = model?.contextWindow;
emit();
// Effort → thinking level; default inherits the parent session.
let thinkingLevel: ThinkingLevel = pi.getThinkingLevel();
if (opts.effort !== undefined) {
const effort = String(opts.effort);
if (!(THINKING_LEVELS as readonly string[]).includes(effort)) {
return fail(
`agent "${label}": invalid effort "${effort}" (use ${THINKING_LEVELS.join("|")})`,
);
}
thinkingLevel = effort as ThinkingLevel;
}
const resources = await getResources(opts.schema !== undefined);
const outcome = await runAgent({
prompt,
schema: opts.schema,
model,
thinkingLevel,
cwd: ctx.cwd,
loader: resources.loader,
settingsManager: resources.settingsManager,
modelRegistry: ctx.modelRegistry,
signal: runSignal,
onProgress: (progress) => {
record.preview = progress.preview.slice(0, PREVIEW_LENGTH);
record.usage = progress.usage;
record.model = progress.model ?? record.model;
record.contextWindow =
progress.contextWindow ?? record.contextWindow;
record.transcript = progress.transcript;
emit();
},
});
record.usage = outcome.usage;
record.model = outcome.model ?? record.model;
record.contextWindow =
outcome.contextWindow ?? record.contextWindow;
record.transcript = outcome.transcript;
record.preview = (outcome.output || record.preview).slice(
0,
PREVIEW_LENGTH,
);
record.finishedAt = Date.now();
record.state = outcome.ok ? "done" : "error";
if (outcome.ok) {
delete record.error;
} else {
record.error = outcome.error ?? "Agent failed";
}
emit();
return {
ok: outcome.ok,
output: outcome.output,
...(outcome.structured !== undefined
? { structured: outcome.structured }
: {}),
...(outcome.error !== undefined ? { error: outcome.error } : {}),
};
}, invocationSignal)
.catch((error) => fail(errorText(error)));
};
const runScript = async () => {
let status: WorkflowDetails["status"] = "completed";
try {
details.result = await runWorkflowSandbox({
source: prepared.source,
args,
cwd: ctx.cwd,
signal: controller.signal,
onAgent: agentFn,
onPhase: phaseFn,
});
} catch (error) {
details.error = errorText(error);
status = controller.signal.aborted ? "aborted" : "failed";
controller.abort("Workflow script failed");
}
const settled = await controller.settle({
abort: status !== "completed",
});
if (!settled) {
status = "failed";
details.error = details.error
? `${details.error}; agent shutdown deadline exceeded`
: "Agent shutdown deadline exceeded";
}
for (const record of details.agents) {
if (record.state !== "running") continue;
record.state = "error";
record.error =
record.error ?? "Agent did not settle before run cleanup";
record.finishedAt = Date.now();
}
details.status = status;
details.finishedAt = Date.now();
try {
persistence.flush();
} catch (error) {
details.status = "failed";
details.error = `Artifact persistence failed: ${errorText(error)}`;
throw new Error(details.error);
} finally {
flushNow();
}
};
// Registered for /workflows visibility and session_shutdown abort;
// blocking runs are watchable live from the dashboard too.
const activeRun = { details, controller } as {
details: WorkflowDetails;
controller: RunController;
completion?: Promise<void>;
};
activeRuns.set(runId, activeRun);
const completion = runScript();
activeRun.completion = completion;
if (ctx.hasUI) lastUi = ctx.ui;
updateIndicator();
if (background) {
void completion
.catch((error) => {
details.status = "failed";
details.finishedAt = Date.now();
details.error = details.error ?? errorText(error);
})
.finally(() => {
activeRuns.delete(runId);
recordSettledRun(details.status);
updateIndicator();
try {
pi.sendUserMessage(
buildBackgroundWorkflowFollowUp({
runId,
status: details.status,
result: buildWorkflowResultMessage(details, runDir),
}),
{ deliverAs: "followUp" },
);
} catch {
// Session may be shutting down.
}
});
return {
content: [
{
type: "text",
text: buildBackgroundWorkflowLaunchResult({
runId,
name: details.name,
runDir,
}),
},
],
details: compactToolDetails(details),
};
}
try {
await completion;
} finally {
activeRuns.delete(runId);
recordSettledRun(details.status);
updateIndicator();
}
if (details.status !== "completed") {
// Pi marks tool failures only when execute throws; returning isError is
// ignored by the extension API.
throw new Error(buildWorkflowResultMessage(details, runDir));
}
return {
content: [
{
type: "text",
text: buildWorkflowResultMessage(details, runDir),
},
],
details: compactToolDetails(details),
};
},
renderCall(args: Partial<WorkflowInput>, theme) {
const meta =
typeof args.script === "string"
? extractMeta(args.script)
: { phases: [] };
let text =
theme.fg("toolTitle", theme.bold("workflow ")) +
theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)");
if (args.background) text += theme.fg("dim", " (background)");
const description = (meta as WorkflowMeta).description;
if (description) text += `\n ${theme.fg("dim", description)}`;
for (const phase of meta.phases.slice(0, 8)) {
text += `\n ${theme.fg("dim", SQUARE)} ${theme.fg("accent", phase.title)}${
phase.detail ? theme.fg("dim", ` — ${phase.detail}`) : ""
}`;
}
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as WorkflowDetails | undefined;
if (!details) {
const first = result.content[0];
return new Text(
first?.type === "text" ? first.text : "(no output)",
0,
0,
);
}
const { done, failed } = countStates(details);
const settled = done + failed;
const elapsed = formatElapsed(details.startedAt, details.finishedAt);
let header =
`${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
`${theme.fg("accent", details.name ?? details.runId)} ` +
theme.fg(
"dim",
`${settled}/${details.agents.length} agents · ${elapsed} · `,
) +
theme.fg(statusColor(details.status), statusWord(details.status));
if (failed) header += theme.fg("error", ` · ${failed} failed`);
if (details.background) header += theme.fg("dim", " (background)");
if (details.status === "running" && details.currentPhase) {
header += theme.fg("muted", ` · ${details.currentPhase}`);
}
const totals = formatUsage(aggregateUsage(details.agents));
if (!expanded) {
let text = header;
for (const agent of details.agents) {
const context = agentContext(agent);
text += `\n ${stateSquare(agent.state, theme)} ${theme.fg("accent", agent.label)}${
agent.phase ? theme.fg("dim", ` (${agent.phase})`) : ""
}${theme.fg(
"dim",
`${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`,
)}`;
}
if (totals) text += `\n ${theme.fg("dim", `Total: ${totals}`)}`;
if (details.error)
text += `\n ${theme.fg("error", `Error: ${details.error}`)}`;
text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`;
return new Text(text, 0, 0);
}
const container = new Container();
container.addChild(new Text(header, 0, 0));
if (details.description) {
container.addChild(
new Text(theme.fg("dim", details.description), 0, 0),
);
}
for (const group of phaseGroups(details)) {
container.addChild(new Spacer(1));
container.addChild(
new Text(theme.fg("muted", `─── ${group.title} ───`), 0, 0),
);
for (const agent of group.agents) {
const usage = formatUsage(agent.usage, agent.model);
const context = agentContext(agent);
let line = `${stateSquare(agent.state, theme)} ${theme.fg("accent", agent.label)} ${theme.fg(
"dim",
[context, formatElapsed(agent.startedAt, agent.finishedAt)]
.filter(Boolean)
.join(" · "),
)}`;
if (usage) line += ` ${theme.fg("dim", usage)}`;
container.addChild(new Text(line, 0, 0));
if (agent.error) {
container.addChild(
new Text(` ${theme.fg("error", agent.error)}`, 0, 0),
);
} else if (agent.preview) {
const preview = agent.preview.split("\n").slice(0, 2).join(" ");
container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0));
}
}
}
if (details.error) {
container.addChild(new Spacer(1));
container.addChild(
new Text(theme.fg("error", `Error: ${details.error}`), 0, 0),
);
}
if (details.result !== undefined) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0));
container.addChild(
new Markdown(
`\`\`\`json\n${resultJson(details.result)}\n\`\`\``,
0,
0,
getMarkdownTheme(),
),
);
}
if (totals) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0));
}
return container;
},
});
}
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { extractMeta, prepareWorkflowScript } from "./meta.ts";
test("metadata is decoded statically and removed from executable source", () => {
const source = `export const meta = {
name: "audit",
description: "safe",
phases: [{ title: "Scan", detail: "files" }],
};
return { ok: true };`;
const prepared = prepareWorkflowScript(source);
assert.deepEqual(prepared.meta, {
name: "audit",
description: "safe",
phases: [{ title: "Scan", detail: "files" }],
});
assert.doesNotMatch(prepared.source, /name:\s*"audit"/);
assert.equal(prepared.source.split("\n").length, source.split("\n").length);
});
test("export-like text in strings, comments, regexes, and templates is untouched", () => {
const source = `
const string = "export default notSyntax";
const template = \`export const meta = \${string}\`;
const regex = /export\\s+default/;
// export const fake = 1
return { string, template, matches: regex.test(string) };
`;
const prepared = prepareWorkflowScript(source);
assert.equal(prepared.source, source);
assert.deepEqual(prepared.meta, { phases: [] });
});
test("executable and unsupported metadata fail closed", () => {
assert.throws(
() =>
prepareWorkflowScript(
`export const meta = { name: (() => "executed")(), phases: [] }; return 1;`,
),
/only static literals/,
);
assert.throws(
() => prepareWorkflowScript(`export default 1; return 1;`),
/may only export/,
);
assert.deepEqual(
extractMeta(`export const meta = { name: process.exit(), phases: [] }`),
{ phases: [] },
);
});
+237
View File
@@ -0,0 +1,237 @@
import {
parse,
type ArrayExpression,
type ExportNamedDeclaration,
type Expression,
type Identifier,
type Literal,
type ObjectExpression,
type Program,
type Property,
type VariableDeclaration,
} from "acorn";
/** Static workflow metadata and source preparation helpers. */
export interface WorkflowPhase {
title: string;
detail?: string;
}
export interface WorkflowMeta {
name?: string;
description?: string;
phases: WorkflowPhase[];
}
export interface PreparedWorkflowScript {
/** Script body with the metadata declaration removed. */
source: string;
meta: WorkflowMeta;
}
function parseProgram(source: string): Program {
return parse(source, {
ecmaVersion: "latest",
sourceType: "module",
allowReturnOutsideFunction: true,
});
}
function isIdentifier(node: Expression, name?: string): node is Identifier {
return (
node.type === "Identifier" && (name === undefined || node.name === name)
);
}
function isLiteral(node: Expression): node is Literal {
return node.type === "Literal";
}
function isProperty(
node: ObjectExpression["properties"][number],
): node is Property {
return node.type === "Property";
}
function propertyName(property: Property) {
if (property.computed || property.kind !== "init" || property.method) {
return undefined;
}
if (isIdentifier(property.key)) return property.key.name;
if (isLiteral(property.key) && typeof property.key.value === "string") {
return property.key.value;
}
return undefined;
}
/**
* Convert an Acorn expression to data without evaluating source. Only plain
* object/array/primitive literals are accepted. Getters, methods, spreads,
* computed keys, templates, identifiers, and calls all fail closed.
*/
function literalValue(node: Expression, depth = 0): unknown {
if (depth > 8) throw new Error("workflow metadata is nested too deeply");
if (isLiteral(node)) {
if (
node.value === null ||
typeof node.value === "string" ||
typeof node.value === "number" ||
typeof node.value === "boolean"
) {
return node.value;
}
throw new Error("workflow metadata contains an unsupported literal");
}
if (node.type === "ArrayExpression") {
const arrayNode: ArrayExpression = node;
return arrayNode.elements.map((element) => {
if (!element || element.type === "SpreadElement") {
throw new Error(
"workflow metadata arrays cannot contain holes or spreads",
);
}
return literalValue(element, depth + 1);
});
}
if (node.type === "ObjectExpression") {
const objectNode: ObjectExpression = node;
const value: Record<string, unknown> = Object.create(null);
for (const item of objectNode.properties) {
if (!isProperty(item)) {
throw new Error("workflow metadata objects cannot contain spreads");
}
const key = propertyName(item);
if (key === undefined || item.shorthand) {
throw new Error(
"workflow metadata keys and values must be plain literals",
);
}
value[key] = literalValue(item.value, depth + 1);
}
return value;
}
throw new Error("workflow metadata must contain only static literals");
}
function sanitizeMeta(value: unknown): WorkflowMeta {
const meta: WorkflowMeta = { phases: [] };
if (!value || typeof value !== "object") return meta;
const raw = value as {
name?: unknown;
description?: unknown;
phases?: unknown;
};
if (typeof raw.name === "string") meta.name = raw.name.slice(0, 160);
if (typeof raw.description === "string") {
meta.description = raw.description.slice(0, 2_000);
}
if (Array.isArray(raw.phases)) {
for (const item of raw.phases.slice(0, 64)) {
if (!item || typeof item !== "object") continue;
const phase = item as { title?: unknown; detail?: unknown };
if (typeof phase.title !== "string" || !phase.title.trim()) continue;
meta.phases.push({
title: phase.title.slice(0, 160),
...(typeof phase.detail === "string"
? { detail: phase.detail.slice(0, 2_000) }
: {}),
});
}
}
return meta;
}
function metadataDeclaration(statement: Program["body"][number]) {
if (statement.type !== "ExportNamedDeclaration") return undefined;
const exported: ExportNamedDeclaration = statement;
if (
exported.source ||
exported.specifiers.length > 0 ||
exported.declaration?.type !== "VariableDeclaration"
) {
throw new Error(
"workflow scripts may only export a static `const meta = {...}` declaration",
);
}
const declaration: VariableDeclaration = exported.declaration;
if (declaration.kind !== "const" || declaration.declarations.length !== 1) {
throw new Error(
"workflow metadata must be declared as `export const meta = {...}`",
);
}
const declarator = declaration.declarations[0];
if (
declarator.id.type !== "Identifier" ||
declarator.id.name !== "meta" ||
!declarator.init
) {
throw new Error("workflow scripts may only export a `meta` declaration");
}
return { exported, initializer: declarator.init };
}
/**
* Parse a workflow as JavaScript, reject module syntax other than the metadata
* API, statically decode metadata, and remove that declaration from the body.
* Source inside strings, comments, regexes, and templates is never rewritten.
*/
export function prepareWorkflowScript(source: string): PreparedWorkflowScript {
const program = parseProgram(source);
let meta: WorkflowMeta = { phases: [] };
let metadataRange: { start: number; end: number } | undefined;
for (const statement of program.body) {
if (statement.type === "ImportDeclaration") {
throw new Error("workflow scripts cannot use static imports");
}
if (
statement.type === "ExportDefaultDeclaration" ||
statement.type === "ExportAllDeclaration"
) {
throw new Error(
"workflow scripts may only export a static `const meta = {...}` declaration",
);
}
const declaration = metadataDeclaration(statement);
if (!declaration) continue;
if (metadataRange)
throw new Error("workflow metadata may only be declared once");
meta = sanitizeMeta(literalValue(declaration.initializer));
metadataRange = {
start: declaration.exported.start,
end: declaration.exported.end,
};
}
if (!metadataRange) return { source, meta };
// Preserve line numbers for parse/runtime errors while removing every byte of
// executable metadata. A semicolon keeps adjacent statements separated.
const removed = source.slice(metadataRange.start, metadataRange.end);
const replacement = `;${removed.slice(1).replace(/[^\n\r]/g, " ")}`;
return {
source:
source.slice(0, metadataRange.start) +
replacement +
source.slice(metadataRange.end),
meta,
};
}
const metaCache = new Map<string, WorkflowMeta>();
const META_CACHE_LIMIT = 32;
/** Safe metadata extraction for render paths. Invalid/partial scripts are empty. */
export function extractMeta(script: string): WorkflowMeta {
const cached = metaCache.get(script);
if (cached) return cached;
let meta: WorkflowMeta = { phases: [] };
try {
meta = prepareWorkflowScript(script).meta;
} catch {
// Renderers must tolerate partial model output and invalid scripts.
}
if (metaCache.size >= META_CACHE_LIMIT) metaCache.clear();
metaCache.set(script, meta);
return meta;
}
+241
View File
@@ -0,0 +1,241 @@
/**
* Shared workflow run model + formatting helpers, used by the tool renderers
* (index.ts) and the /workflows dashboard (dashboard.ts).
*/
import * as os from "node:os";
import {
truncateHead,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { formatContextUtilization } from "../shared/context-utilization.ts";
import { safeStringify } from "./serialization.ts";
export type Theme = ExtensionContext["ui"]["theme"];
export const RESULT_JSON_MAX_BYTES = 24 * 1024;
export const RESULT_JSON_MAX_LINES = 600;
export interface AgentUsage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
/** Latest compaction-aware conversation occupancy, not cumulative billing. */
contextTokens?: number;
turns: number;
}
export function emptyUsage(): AgentUsage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: 0,
turns: 0,
};
}
export type AgentState = "running" | "done" | "error";
export type WorkflowStatus = "running" | "completed" | "failed" | "aborted";
export type TranscriptRole =
"user" | "assistant" | "thinking" | "tool" | "toolResult";
export interface TranscriptEntry {
role: TranscriptRole;
text: string;
/** Tool name for tool calls/results. */
name?: string;
/** Stable tool-call identifier used to pair calls, results, and timings. */
toolCallId?: string;
isError?: boolean;
/** Original message timestamp, when provided by the model/session. */
timestamp?: number;
/** Tool execution lifecycle timestamps, measured by the child session. */
startedAt?: number;
finishedAt?: number;
durationMs?: number;
}
export interface AgentRecord {
index: number;
label: string;
phase?: string;
state: AgentState;
model?: string;
/** Context capacity of the active model used for this agent. */
contextWindow?: number;
startedAt: number;
finishedAt?: number;
error?: string;
preview: string;
usage: AgentUsage;
/** Normalized, serializable subagent conversation shown by /workflows. */
transcript: TranscriptEntry[];
}
export interface WorkflowDetails {
runId: string;
/** Pi session that launched this run. */
sessionId?: string;
name?: string;
description?: string;
background: boolean;
status: WorkflowStatus;
startedAt: number;
finishedAt?: number;
phases: { title: string; detail?: string }[];
currentPhase?: string;
agents: AgentRecord[];
result?: unknown;
resultArtifact?: string;
transcriptArtifact?: string;
error?: string;
}
/** Colored square state indicator (no emojis/glyphs). */
export const SQUARE = "■";
export function stateSquare(state: AgentState, theme: Theme): string {
if (state === "done") return theme.fg("success", SQUARE);
if (state === "error") return theme.fg("error", SQUARE);
return theme.fg("warning", SQUARE);
}
export function statusSquare(status: WorkflowStatus, theme: Theme): string {
if (status === "completed") return theme.fg("success", SQUARE);
if (status === "running") return theme.fg("warning", SQUARE);
return theme.fg("error", SQUARE);
}
export function statusWord(status: WorkflowStatus): string {
return status === "completed" ? "done" : status;
}
export function statusColor(
status: WorkflowStatus,
): "success" | "warning" | "error" {
if (status === "completed") return "success";
if (status === "running") return "warning";
return "error";
}
export function shortenHome(p: string): string {
const home = os.homedir();
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
}
export function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
export function formatUsage(usage: AgentUsage, model?: string): string {
const parts: string[] = [];
if (usage.turns)
parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
if (usage.input) parts.push(`${formatTokens(usage.input)} in`);
if (usage.output) parts.push(`${formatTokens(usage.output)} out`);
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
if (model) parts.push(model);
return parts.join(" · ");
}
/** Current per-agent context-window utilization, e.g. "7%/372k". */
export function agentContext(agent: AgentRecord): string {
return formatContextUtilization({
tokens: agent.usage.contextTokens,
contextWindow: agent.contextWindow,
});
}
export function formatElapsed(startedAt: number, finishedAt?: number): string {
const totalSeconds = Math.max(
0,
Math.round(((finishedAt ?? Date.now()) - startedAt) / 1000),
);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0
? `${minutes}m${seconds.toString().padStart(2, "0")}s`
: `${seconds}s`;
}
export function aggregateUsage(agents: AgentRecord[]): AgentUsage {
const total = emptyUsage();
for (const agent of agents) {
total.input += agent.usage.input;
total.output += agent.usage.output;
total.cacheRead += agent.usage.cacheRead;
total.cacheWrite += agent.usage.cacheWrite;
total.cost += agent.usage.cost;
total.turns += agent.usage.turns;
}
return total;
}
export function countStates(details: WorkflowDetails) {
let done = 0;
let failed = 0;
let running = 0;
for (const agent of details.agents) {
if (agent.state === "done") done++;
else if (agent.state === "error") failed++;
else running++;
}
return { done, failed, running };
}
export interface PhaseGroup {
title: string;
agents: AgentRecord[];
}
/**
* Group agents by phase in declared phase order. With `includeEmpty`, phases
* that have no agents yet are still listed (used by the dashboard sidebar).
*/
export function phaseGroups(
details: WorkflowDetails,
includeEmpty = false,
): PhaseGroup[] {
const byPhase = new Map<string, AgentRecord[]>();
for (const agent of details.agents) {
const key = agent.phase ?? "(unphased)";
const list = byPhase.get(key) ?? [];
list.push(agent);
byPhase.set(key, list);
}
const groups: PhaseGroup[] = [];
for (const phase of details.phases) {
const agents = byPhase.get(phase.title);
if (agents || includeEmpty)
groups.push({ title: phase.title, agents: agents ?? [] });
byPhase.delete(phase.title);
}
for (const [title, agents] of byPhase) groups.push({ title, agents });
return groups;
}
export function resultJson(value: unknown): string {
const text =
typeof value === "string"
? value
: safeStringify(value, {
maxBytes: RESULT_JSON_MAX_BYTES * 2,
maxDepth: 16,
maxNodes: 10_000,
});
const truncation = truncateHead(text ?? "", {
maxLines: RESULT_JSON_MAX_LINES,
maxBytes: RESULT_JSON_MAX_BYTES,
});
return truncation.truncated
? `${truncation.content}\n…[result truncated; bounded result artifact in result.json]`
: truncation.content;
}
+118
View File
@@ -0,0 +1,118 @@
import {
countStates,
formatElapsed,
resultJson,
shortenHome,
type WorkflowDetails,
} from "./model.ts";
/** Model-facing schema descriptions for workflow source, arguments, and background mode. */
export const WORKFLOW_PARAMETER_DESCRIPTIONS = {
script:
"JavaScript workflow script. May start with `export const meta = {...}`, then use phase(), agent(), parallel(), args, and a final `return`.",
args: "Optional JSON string exposed to the script as `args` (parsed when valid JSON, otherwise passed through as the raw string).",
background:
"Run in the background: the tool returns a run id immediately and you receive a follow-up message when the workflow finishes. Defaults to false (blocking with live progress).",
};
/** Defines the workflow DSL, constraints, reliability guidance, and model-authored task examples. */
export const WORKFLOW_TOOL_DESCRIPTION = [
"The workflow tool is only to be called when the user says 'ultracode' or specifically requests a workflow run.",
"Run a multi-agent workflow from a JavaScript orchestration script you write inline. Use this when a task benefits from fanning work out across several isolated subagents in ordered phases (research fan-out, per-file review, verify-then-synthesize pipelines).",
"The script runs as an async function body with these primitives:",
"• export const meta = { name, description, phases: [{ title, detail? }] } — metadata for the progress UI. Declare all phases up front.",
"• phase(title) — mark the current phase at runtime (use titles from meta.phases).",
"• await agent(prompt, { label?, phase?, schema?, model?, provider?, effort? }) — run ONE subagent in an isolated context and wait for it. Always resolves to { ok, output, structured?, error? }. Check `ok` before using the result. When you pass a JSON `schema`, `structured` holds the validated object on success. `model`/`provider` override the session model; `effort` sets the thinking level (off|minimal|low|medium|high|xhigh|max). Children receive normal built-ins and trust-appropriate extensions, settings, skills, and AGENTS.md context, but cannot recursively orchestrate or ask the user.",
"• await parallel([() => agent(...), () => agent(...)], { concurrency? }) — run zero-argument agent thunks concurrently and return results in order. Concurrency is globally capped at 4 for the run.",
"• args — the parsed value of the `args` tool parameter (or undefined).",
"Workflow JavaScript runs in a restricted, killable child with no imports, eval, timers, filesystem, network, or process APIs. A run may make at most 32 agent calls and has no overall deadline. Each agent must receive its first assistant response event within 45 seconds so silent provider requests fail clearly; after that, agent() has no wall-clock deadline. Each individual child tool call times out independently after 3 minutes, becomes an error tool result, and leaves the agent loop free to recover. Use map/filter/if/await/template strings to orchestrate, and `return` a JSON-serializable aggregate.",
"Pass a `schema` to agent() whenever a later step branches on the result, so you get typed fields instead of prose. There is no resume: a failed run is simply re-run. Artifacts are saved under ~/.pi/agent/workflows/<runId>/ for inspection.",
"Example:",
"export const meta = { name: 'reliability-review', description: 'Review modules for reliability risks, then report', phases: [{ title: 'Scan' }, { title: 'Report' }] }",
"const FINDINGS = { type: 'object', properties: { issues: { type: 'array', items: { type: 'string' } }, ok: { type: 'boolean' } }, required: ['issues', 'ok'] }",
"phase('Scan')",
"const scans = await parallel(args.files.map((f) => () => agent(`Review ${f} for correctness and reliability risks.`, { label: `scan:${f}`, phase: 'Scan', schema: FINDINGS })))",
"const findings = scans.filter((r) => r.ok).map((r) => r.structured)",
"phase('Report')",
"const report = await agent(`Summarize these findings: ${JSON.stringify(findings)}`, { label: 'report', phase: 'Report' })",
"return { findings, report: report.ok ? report.output : report.error }",
].join("\n");
/** Adds workflow orchestration primitives and background execution to the model's tool prompt. */
export const WORKFLOW_PROMPT_SNIPPET =
"Orchestrate isolated subagents from an inline JS script: phase()/agent()/parallel() with structured outputs and optional background execution";
/** Guides the model on appropriate workflow fan-out and mandatory agent result checks. */
export const WORKFLOW_PROMPT_GUIDELINES = [
"Use workflow when a task needs several subagents with phase dependencies or dynamic fan-out; keep single small delegations in the main session.",
"In workflow scripts, agent() never throws — always check `.ok` on its result before using `.output`/`.structured`.",
];
/** Marks and forwards a workflow script's agent() task as an isolated child-model prompt. */
export function buildWorkflowAgentPrompt(prompt: string) {
return prompt;
}
/** Instructs structured workflow children to terminate with exactly one structured_output call. */
export const STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION =
"When your task is complete, call the `structured_output` tool exactly once as your final action, with fields matching the required schema. Do not write any other text after it.";
/** Describes the terminating structured_output tool and its final-action contract. */
export const STRUCTURED_OUTPUT_TOOL_DESCRIPTION =
"Return your final result as structured data matching the required schema. Call this exactly once, as your last action; do not write any other text after it.";
/** Builds the workflow completion report returned to the parent model. */
export function buildWorkflowResultMessage(
details: WorkflowDetails,
runDir: string,
) {
const { done, failed } = countStates(details);
const elapsed = formatElapsed(details.startedAt, details.finishedAt);
const lines = [
`Workflow ${details.name ? `"${details.name}"` : details.runId} ${details.status} — ` +
`${done}/${details.agents.length} agents ok${failed ? `, ${failed} failed` : ""} ` +
`across ${details.phases.length} phase(s) in ${elapsed}.`,
`Run dir: ${shortenHome(runDir)}`,
];
if (details.error) lines.push(`Error: ${details.error}`);
if (details.agents.length > 0) {
lines.push("", "Agents:");
for (const agent of details.agents) {
const status =
agent.state === "done"
? "ok"
: agent.state === "error"
? "FAILED"
: "running";
lines.push(
`- [${agent.label}]${agent.phase ? ` (${agent.phase})` : ""} ${status}` +
(agent.error ? ` — ${agent.error}` : ""),
);
}
}
if (details.result !== undefined)
lines.push("", "Result:", resultJson(details.result));
return lines.join("\n");
}
/** Builds the follow-up user message that delivers a settled background workflow to the parent model. */
export function buildBackgroundWorkflowFollowUp(options: {
runId: string;
status: WorkflowDetails["status"];
result: string;
}) {
return `[Background workflow ${options.runId} ${options.status}]\n\n${options.result}`;
}
/** Builds the background-launch result and tells the parent model where progress and artifacts appear. */
export function buildBackgroundWorkflowLaunchResult(options: {
runId: string;
name?: string;
runDir: string;
}) {
return [
`Workflow ${options.name ? `"${options.name}"` : options.runId} launched in background (run ${options.runId}).`,
`Artifacts: ${shortenHome(options.runDir)}`,
"You'll receive a follow-up message when it finishes; /workflows shows progress.",
].join("\n");
}
+273
View File
@@ -0,0 +1,273 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type {
AgentSession,
AgentSessionEventListener,
ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
createFirstResponseWatchdog,
guardWorkflowChildTools,
recordToolExecutionTiming,
transcriptFromMessages,
type ToolExecutionTiming,
} from "./runner.ts";
const zeroUsage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
function parallelToolMessages(): AgentSession["messages"] {
return [
{ role: "user", content: "run both", timestamp: 900 },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-a",
name: "first",
arguments: { value: 1 },
},
{
type: "toolCall",
id: "call-b",
name: "second",
arguments: { value: 2 },
},
],
api: "openai-responses",
provider: "fixture",
model: "fixture",
usage: zeroUsage,
stopReason: "toolUse",
timestamp: 950,
},
{
role: "toolResult",
toolCallId: "call-a",
toolName: "first",
content: [{ type: "text", text: "first result" }],
isError: false,
timestamp: 1_040,
},
{
role: "toolResult",
toolCallId: "call-b",
toolName: "second",
content: [{ type: "text", text: "second result" }],
isError: false,
timestamp: 1_041,
},
];
}
test("completed parallel tool calls pair lifecycle timings with calls and results", () => {
const timings = new Map<string, ToolExecutionTiming>();
recordToolExecutionTiming(
timings,
{
type: "tool_execution_start",
toolCallId: "call-a",
toolName: "first",
args: { value: 1 },
},
1_000,
);
recordToolExecutionTiming(
timings,
{
type: "tool_execution_start",
toolCallId: "call-b",
toolName: "second",
args: { value: 2 },
},
1_002,
);
// Parallel calls can finish in a different order than their result messages.
recordToolExecutionTiming(
timings,
{
type: "tool_execution_end",
toolCallId: "call-b",
toolName: "second",
result: { content: [{ type: "text", text: "second result" }] },
isError: false,
},
1_012,
);
recordToolExecutionTiming(
timings,
{
type: "tool_execution_end",
toolCallId: "call-a",
toolName: "first",
result: { content: [{ type: "text", text: "first result" }] },
isError: false,
},
1_030,
);
const transcript = transcriptFromMessages(parallelToolMessages(), timings);
const toolEntries = transcript.filter((entry) => entry.role === "tool");
const resultEntries = transcript.filter(
(entry) => entry.role === "toolResult",
);
for (const entries of [toolEntries, resultEntries]) {
assert.deepEqual(
entries.map(({ toolCallId, startedAt, finishedAt, durationMs }) => ({
toolCallId,
startedAt,
finishedAt,
durationMs,
})),
[
{
toolCallId: "call-a",
startedAt: 1_000,
finishedAt: 1_030,
durationMs: 30,
},
{
toolCallId: "call-b",
startedAt: 1_002,
finishedAt: 1_012,
durationMs: 10,
},
],
);
}
});
test("in-flight aborted tool calls retain start timing without completion", () => {
const timings = new Map<string, ToolExecutionTiming>();
recordToolExecutionTiming(
timings,
{
type: "tool_execution_start",
toolCallId: "call-a",
toolName: "first",
args: { value: 1 },
},
2_000,
);
const transcript = transcriptFromMessages(
parallelToolMessages().slice(0, 2),
timings,
);
const first = transcript.find((entry) => entry.toolCallId === "call-a");
assert.equal(first?.startedAt, 2_000);
assert.equal(first?.finishedAt, undefined);
assert.equal(first?.durationMs, undefined);
assert.equal(
transcript.some((entry) => entry.role === "toolResult"),
false,
);
});
test("first-response watchdog aborts a silent provider request", async () => {
let aborted = false;
const watchdog = createFirstResponseWatchdog(
async () => {
aborted = true;
},
{ timeoutMs: 10, model: "fixture-model" },
);
await assert.rejects(
watchdog.waitFor(new Promise<never>(() => {})),
/no assistant response event for fixture-model within 10 ms.*stalled/i,
);
assert.equal(aborted, true);
});
test("first assistant response disarms the watchdog without limiting the run", async () => {
const watchdog = createFirstResponseWatchdog(
async () => {
throw new Error("watchdog should have been disarmed");
},
{ timeoutMs: 10 },
);
watchdog.markResponse();
const result = await watchdog.waitFor(
new Promise<string>((resolve) => setTimeout(() => resolve("done"), 20)),
);
assert.equal(result, "done");
});
test("workflow children guard structured, normal, and dynamically registered tools", async () => {
const structuredResult = {
content: [{ type: "text" as const, text: "recorded" }],
details: { value: "fixture" },
terminate: true,
};
const structured = {
name: "structured_output",
label: "Structured Output",
description: "fixture",
parameters: Type.Object({}),
async execute() {
return structuredResult;
},
} satisfies ToolDefinition;
const definitions = new Map<string, ToolDefinition>([
[structured.name, structured],
]);
let listener: AgentSessionEventListener | undefined;
const session = {
getAllTools: () => [...definitions.keys()].map((name) => ({ name })),
getToolDefinition: (name: string) => definitions.get(name),
subscribe(next: AgentSessionEventListener) {
listener = next;
return () => {
listener = undefined;
};
},
};
const unsubscribe = guardWorkflowChildTools(session, 10);
assert.equal(await structured.execute(), structuredResult);
let dynamicSignal: AbortSignal | undefined;
const dynamic = {
name: "dynamic_fixture",
label: "Dynamic Fixture",
description: "fixture",
parameters: Type.Object({}),
async execute(
_toolCallId: string,
_params: Record<string, never>,
signal?: AbortSignal,
) {
dynamicSignal = signal;
return new Promise<never>(() => {});
},
} satisfies ToolDefinition;
const originalDynamicExecute = dynamic.execute;
definitions.set(dynamic.name, dynamic);
listener?.({ type: "agent_start" });
assert.notEqual(dynamic.execute, originalDynamicExecute);
await assert.rejects(
dynamic.execute("fixture", {}, undefined),
/Tool call "dynamic_fixture" timed out after 10 ms\./,
);
assert.equal(dynamicSignal?.aborted, true);
unsubscribe();
});
+650
View File
@@ -0,0 +1,650 @@
/**
* Workflow subagent runner.
*
* Each `agent()` call in a workflow script becomes one isolated in-process
* AgentSession created here: in-memory session, normal trust-aware resources
* and extensions, recursive orchestration/user-prompt tools denied, and an
* optional one-shot `structured_output` tool when a schema is supplied.
*
* `runAgent()` never throws: every failure mode (session creation, provider
* errors, aborts, missing structured output) settles into an `AgentOutcome`.
*/
import {
createAgentSession,
DefaultResourceLoader,
defineTool,
SessionManager,
SettingsManager,
type AgentSession,
type AgentSessionEvent,
type AgentSessionEventListener,
type ExtensionAPI,
type ExtensionContext,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Type, type TSchema } from "typebox";
import {
bindChildSessionExtensions,
childToolPolicy,
createChildResources,
shutdownAndDisposeChildSession,
} from "../shared/child-session.ts";
import { createToolCallTimeoutGuard } from "../shared/tool-call-timeout.ts";
import { emptyUsage, type AgentUsage, type TranscriptEntry } from "./model.ts";
import {
buildWorkflowAgentPrompt,
STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
} from "./prompt.ts";
import { safeStringify, truncateUtf8 } from "./serialization.ts";
const AGENT_OUTPUT_MAX_BYTES = 64 * 1024;
export const FIRST_RESPONSE_TIMEOUT_MS = 45_000;
const TRANSCRIPT_ENTRY_MAX_BYTES = 16 * 1024;
const TRANSCRIPT_TOTAL_MAX_BYTES = 256 * 1024;
const TRANSCRIPT_MAX_ENTRIES = 200;
export type WorkflowModel = NonNullable<ExtensionContext["model"]>;
export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
type AgentMessage = AgentSession["messages"][number];
type ToolTimingEvent = Extract<
AgentSessionEvent,
{ type: "tool_execution_start" | "tool_execution_end" }
>;
export interface ToolExecutionTiming {
startedAt?: number;
finishedAt?: number;
durationMs?: number;
}
export interface AgentOutcome {
ok: boolean;
/** Final assistant text (may be empty when only structured output was produced). */
output: string;
/** Captured structured_output payload when a schema was supplied. */
structured?: unknown;
error?: string;
aborted: boolean;
usage: AgentUsage;
model?: string;
contextWindow?: number;
transcript: TranscriptEntry[];
}
export interface AgentProgress {
preview: string;
usage: AgentUsage;
model?: string;
contextWindow?: number;
transcript: TranscriptEntry[];
}
export interface RunAgentOptions {
prompt: string;
schema?: unknown;
model?: WorkflowModel;
thinkingLevel?: ThinkingLevel;
cwd: string;
loader: DefaultResourceLoader;
settingsManager: SettingsManager;
modelRegistry: ExtensionContext["modelRegistry"];
signal?: AbortSignal;
onProgress?: (progress: AgentProgress) => void;
/** Test-only override for the per-tool execution timeout. */
toolCallTimeoutMs?: number;
/** Test-only override for the first assistant response-event timeout. */
firstResponseTimeoutMs?: number;
}
/** Build a fresh extension runtime for each concurrent workflow child. */
export function createWorkflowResources(
cwd: string,
variant: "plain" | "structured",
projectTrusted: boolean,
) {
return createChildResources({
cwd,
projectTrusted,
...(variant === "structured"
? { appendSystemPrompt: [STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION] }
: {}),
});
}
interface WorkflowToolSession {
getAllTools(): Array<{ name: string }>;
getToolDefinition(name: string): ToolDefinition | undefined;
subscribe(listener: AgentSessionEventListener): () => void;
}
/** Guard current tools and tools registered by extensions at later agent starts. */
export function guardWorkflowChildTools(
session: WorkflowToolSession,
timeoutMs?: number,
) {
const guard = createToolCallTimeoutGuard(timeoutMs);
guard.apply(session);
return session.subscribe((event) => {
if (event.type === "agent_start") guard.apply(session);
});
}
function isJsonSchema(value: unknown): value is TSchema {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const seen = new WeakSet<object>();
let nodes = 0;
const validate = (current: unknown, depth: number): boolean => {
if (++nodes > 10_000 || depth > 24) return false;
if (
current === null ||
typeof current === "string" ||
typeof current === "boolean"
) {
return true;
}
if (typeof current === "number") return Number.isFinite(current);
if (Array.isArray(current)) {
return current.every((item) => validate(item, depth + 1));
}
if (typeof current !== "object") return false;
if (seen.has(current)) return false;
seen.add(current);
return Object.keys(current).every((key) => {
if (key === "__proto__" || key === "constructor" || key === "prototype") {
return false;
}
return validate((current as Record<string, unknown>)[key], depth + 1);
});
};
return validate(value, 0);
}
/** Preserve the caller's full JSON Schema instead of lossy keyword conversion. */
function jsonSchemaToTypebox(schema: unknown): TSchema {
if (!isJsonSchema(schema)) {
throw new Error("structured output schema must be a bounded JSON object");
}
return Type.Unsafe(schema);
}
/**
* One-shot terminating tool injected when a schema is supplied: the subagent
* calls it as its final action and we capture the validated object.
*/
function makeStructuredOutputTool(
schema: unknown,
capture: (value: unknown) => void,
): ToolDefinition {
return defineTool({
name: "structured_output",
label: "Structured Output",
description: STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
parameters: jsonSchemaToTypebox(schema),
async execute(_toolCallId, params) {
capture(params);
return {
content: [{ type: "text", text: "Recorded structured result." }],
details: params,
terminate: true,
};
},
});
}
function finalOutput(messages: AgentMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
const text = msg.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (text) return text;
}
return "";
}
function safeJson(value: unknown): string {
return safeStringify(value, {
maxBytes: TRANSCRIPT_ENTRY_MAX_BYTES,
maxDepth: 12,
maxNodes: 2_000,
});
}
/** Record lifecycle timings without inferring completion from message timestamps. */
export function recordToolExecutionTiming(
timings: Map<string, ToolExecutionTiming>,
event: ToolTimingEvent,
observedAt = Date.now(),
) {
const previous = timings.get(event.toolCallId);
if (event.type === "tool_execution_start") {
if (previous?.startedAt !== undefined) return;
timings.set(event.toolCallId, { ...previous, startedAt: observedAt });
return;
}
if (previous?.finishedAt !== undefined) return;
const durationMs =
previous?.startedAt === undefined
? undefined
: Math.max(0, observedAt - previous.startedAt);
timings.set(event.toolCallId, {
...previous,
finishedAt: observedAt,
...(durationMs === undefined ? {} : { durationMs }),
});
}
function toolMetadata(
toolCallId: string,
timings: ReadonlyMap<string, ToolExecutionTiming>,
) {
const timing = timings.get(toolCallId);
return {
toolCallId: truncateUtf8(toolCallId, 1024),
...(timing?.startedAt === undefined ? {} : { startedAt: timing.startedAt }),
...(timing?.finishedAt === undefined
? {}
: { finishedAt: timing.finishedAt }),
...(timing?.durationMs === undefined
? {}
: { durationMs: timing.durationMs }),
};
}
/** Convert pi messages into a compact, serializable transcript for the UI. */
export function transcriptFromMessages(
messages: AgentMessage[],
toolTimings: ReadonlyMap<string, ToolExecutionTiming> = new Map(),
): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
for (const message of messages) {
if (message.role === "user") {
const text =
typeof message.content === "string"
? message.content
: message.content
.map((part) =>
part.type === "text" ? part.text : `[image: ${part.mimeType}]`,
)
.join("\n");
if (text.trim()) {
entries.push({ role: "user", text, timestamp: message.timestamp });
}
continue;
}
if (message.role === "assistant") {
for (const part of message.content) {
if (part.type === "text" && part.text.trim()) {
entries.push({
role: "assistant",
text: part.text,
timestamp: message.timestamp,
});
} else if (part.type === "thinking" && part.thinking.trim()) {
entries.push({
role: "thinking",
text: part.thinking,
timestamp: message.timestamp,
});
} else if (part.type === "toolCall") {
entries.push({
role: "tool",
name: part.name,
text: safeJson(part.arguments),
timestamp: message.timestamp,
...toolMetadata(part.id, toolTimings),
});
}
}
continue;
}
if (message.role !== "toolResult") continue;
const text = message.content
.map((part) =>
part.type === "text" ? part.text : `[image: ${part.mimeType}]`,
)
.join("\n");
entries.push({
role: "toolResult",
name: message.toolName,
text,
isError: message.isError,
timestamp: message.timestamp,
...toolMetadata(message.toolCallId, toolTimings),
});
}
const selected =
entries.length <= TRANSCRIPT_MAX_ENTRIES
? entries
: [entries[0], ...entries.slice(-(TRANSCRIPT_MAX_ENTRIES - 1))];
const bounded: TranscriptEntry[] = [];
let totalBytes = 0;
for (const entry of selected) {
const remaining = TRANSCRIPT_TOTAL_MAX_BYTES - totalBytes;
if (remaining <= 0) break;
const text = truncateUtf8(
entry.text,
Math.min(TRANSCRIPT_ENTRY_MAX_BYTES, remaining),
);
totalBytes += Buffer.byteLength(text, "utf8");
bounded.push({
...entry,
text:
text === entry.text ? text : `${text}\n[transcript entry truncated]`,
});
}
if (bounded.length < entries.length) {
bounded.push({
role: "toolResult",
name: "transcript",
text: `[transcript truncated: retained ${bounded.length} of ${entries.length} entries]`,
});
}
return bounded;
}
function computeUsage(messages: AgentMessage[]): AgentUsage {
const usage = emptyUsage();
for (const msg of messages) {
if (msg.role !== "assistant") continue;
usage.turns++;
const u = msg.usage;
if (!u) continue;
usage.input += u.input || 0;
usage.output += u.output || 0;
usage.cacheRead += u.cacheRead || 0;
usage.cacheWrite += u.cacheWrite || 0;
usage.cost += u.cost?.total || 0;
}
return usage;
}
function errorText(error: unknown): string {
return (error instanceof Error ? error.message : String(error)).slice(
0,
16 * 1024,
);
}
function formatTimeout(timeoutMs: number) {
return timeoutMs % 1_000 === 0
? `${timeoutMs / 1_000} seconds`
: `${timeoutMs} ms`;
}
/** Abort a provider call that opens but never emits its first assistant event. */
export function createFirstResponseWatchdog(
onTimeout: () => Promise<unknown>,
options: { timeoutMs?: number; model?: string } = {},
) {
const timeoutMs = options.timeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
timer = undefined;
const model = options.model ? ` for ${options.model}` : "";
reject(
new Error(
`Agent received no assistant response event${model} within ${formatTimeout(timeoutMs)}; the provider request may be stalled. Retry the workflow.`,
),
);
void onTimeout().catch(() => {});
}, timeoutMs);
timer.unref?.();
});
const cancel = () => {
if (timer) clearTimeout(timer);
timer = undefined;
};
return {
markResponse: cancel,
async waitFor<T>(operation: Promise<T>) {
try {
return await Promise.race([operation, timeout]);
} finally {
cancel();
}
},
};
}
function isAssistantResponseEvent(event: AgentSessionEvent) {
return (
(event.type === "message_start" ||
event.type === "message_update" ||
event.type === "message_end") &&
event.message.role === "assistant"
);
}
export async function runAgent(
options: RunAgentOptions,
): Promise<AgentOutcome> {
let structured: unknown;
let customTools: ToolDefinition[] | undefined;
let session: AgentSession | undefined;
let unsubscribeToolTimeout: (() => void) | undefined;
try {
customTools =
options.schema !== undefined
? [
makeStructuredOutputTool(options.schema, (value) => {
structured = value;
}),
]
: undefined;
({ session } = await createAgentSession({
cwd: options.cwd,
...(options.model ? { model: options.model } : {}),
...(options.thinkingLevel
? { thinkingLevel: options.thinkingLevel }
: {}),
resourceLoader: options.loader,
settingsManager: options.settingsManager,
sessionManager: SessionManager.inMemory(options.cwd),
...(customTools ? { customTools } : {}),
...childToolPolicy(),
}));
await bindChildSessionExtensions(session);
unsubscribeToolTimeout = guardWorkflowChildTools(
session,
options.toolCallTimeoutMs,
);
} catch (error) {
unsubscribeToolTimeout?.();
if (session) await shutdownAndDisposeChildSession(session);
return {
ok: false,
output: "",
error: `Failed to create agent session: ${errorText(error)}`,
aborted: false,
usage: emptyUsage(),
model: options.model?.id,
contextWindow: options.model?.contextWindow,
transcript: [],
};
}
const childSession = session;
let usage = emptyUsage();
let modelId = childSession.model?.id ?? options.model?.id;
let contextWindow = childSession.model?.contextWindow;
let stopReason: string | undefined;
let errorMessage: string | undefined;
const toolTimings = new Map<string, ToolExecutionTiming>();
const sync = () => {
const messages = childSession.messages;
usage = computeUsage(messages);
const sessionModel = childSession.model;
modelId = sessionModel?.id ?? modelId;
contextWindow = sessionModel?.contextWindow ?? contextWindow;
const context = childSession.getContextUsage();
if (
typeof context?.tokens === "number" &&
Number.isFinite(context.tokens) &&
context.tokens >= 0
) {
usage.contextTokens = context.tokens;
}
if (
typeof context?.contextWindow === "number" &&
Number.isFinite(context.contextWindow) &&
context.contextWindow > 0
) {
contextWindow = context.contextWindow;
}
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
// Some gateways report a concrete fallback model. Prefer its registry
// metadata when available so capacity tracks the model that served the
// latest response rather than a hardcoded/configured guess.
const responseMatchesSession =
!sessionModel ||
(msg.provider === sessionModel.provider &&
msg.model === sessionModel.id);
const reportedId = msg.responseModel ?? msg.model;
const reportedModel = responseMatchesSession
? options.modelRegistry.find(msg.provider, reportedId)
: undefined;
if (reportedModel) {
modelId = reportedModel.id;
contextWindow = reportedModel.contextWindow;
}
if (msg.stopReason) stopReason = msg.stopReason;
if (msg.errorMessage) errorMessage = msg.errorMessage;
break;
}
};
let markFirstResponse = () => {};
const unsubscribe = childSession.subscribe((event) => {
if (isAssistantResponseEvent(event)) markFirstResponse();
if (
event.type === "tool_execution_start" ||
event.type === "tool_execution_end"
) {
recordToolExecutionTiming(toolTimings, event);
} else if (
event.type !== "message_end" &&
event.type !== "compaction_end"
) {
return;
}
sync();
options.onProgress?.({
preview: finalOutput(childSession.messages),
usage,
model: modelId,
contextWindow,
transcript: transcriptFromMessages(childSession.messages, toolTimings),
});
});
let aborted = false;
let abortPromise: Promise<void> | undefined;
const onAbort = () => {
aborted = true;
abortPromise ??= childSession.abort().catch(() => {});
};
if (options.signal) {
if (options.signal.aborted) onAbort();
else options.signal.addEventListener("abort", onAbort, { once: true });
}
let output = "";
let transcript: TranscriptEntry[] = [];
try {
if (!aborted) {
const watchdog = createFirstResponseWatchdog(() => childSession.abort(), {
timeoutMs: options.firstResponseTimeoutMs,
model: modelId,
});
markFirstResponse = watchdog.markResponse;
await watchdog.waitFor(
childSession.prompt(buildWorkflowAgentPrompt(options.prompt)),
);
}
} catch (error) {
errorMessage = errorMessage ?? errorText(error);
stopReason = stopReason ?? "error";
} finally {
options.signal?.removeEventListener("abort", onAbort);
if (abortPromise) await abortPromise;
unsubscribe();
unsubscribeToolTimeout?.();
sync();
output = truncateUtf8(
finalOutput(childSession.messages),
AGENT_OUTPUT_MAX_BYTES,
);
transcript = transcriptFromMessages(childSession.messages, toolTimings);
await shutdownAndDisposeChildSession(childSession);
}
if (aborted || stopReason === "aborted") {
return {
ok: false,
output,
structured,
error: "Agent was aborted",
aborted: true,
usage,
model: modelId,
contextWindow,
transcript,
};
}
const failed = stopReason === "error" || errorMessage !== undefined;
if (failed) {
return {
ok: false,
output,
structured,
error: errorMessage ?? "Agent failed",
aborted: false,
usage,
model: modelId,
contextWindow,
transcript,
};
}
if (options.schema !== undefined && structured === undefined) {
return {
ok: false,
output,
error:
"Agent finished without calling structured_output; no structured result matching the schema was produced.",
aborted: false,
usage,
model: modelId,
contextWindow,
transcript,
};
}
return {
ok: true,
output,
structured,
aborted: false,
usage,
model: modelId,
contextWindow,
transcript,
};
}
+274
View File
@@ -0,0 +1,274 @@
"use strict";
// This file is launched by sandbox.ts in Node permission mode. It deliberately
// has no filesystem/network/child-process permissions and receives workflow
// source only over a validated IPC channel.
const vm = require("node:vm");
const sendIpc =
typeof process.send === "function" ? process.send.bind(process) : undefined;
// If a future V8 escape exposes `process`, remove the convenient bridges to
// builtins, native bindings, parent signalling, and addons before any workflow
// source is compiled. The parent still enforces the authenticated IPC protocol.
for (const capability of [
"getBuiltinModule",
"binding",
"_linkedBinding",
"dlopen",
"kill",
"abort",
"send",
]) {
try {
Object.defineProperty(process, capability, {
value: undefined,
writable: false,
configurable: false,
});
} catch {
// The VM boundary and permission mode remain mandatory controls.
}
}
const BOOTSTRAP = String.raw`
(function bootstrapWorkflowApi() {
"use strict";
const callHost = globalThis.__hostBridge;
delete globalThis.__hostBridge;
let nextRequestId = 0;
const unconsumed = new Set();
const inFlight = new Set();
function deepFreeze(value, depth = 0) {
if (!value || typeof value !== "object" || depth > 32 || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const key of Object.keys(value)) deepFreeze(value[key], depth + 1);
return value;
}
function requestAgent(promptValue, optionsValue = {}) {
const id = ++nextRequestId;
unconsumed.add(id);
let started;
const begin = () => {
unconsumed.delete(id);
if (!started) {
let payload;
try {
payload = JSON.stringify({
id,
prompt: typeof promptValue === "string" ? promptValue : String(promptValue ?? ""),
options: optionsValue && typeof optionsValue === "object" ? optionsValue : {},
});
} catch (error) {
started = Promise.reject(new Error("agent() arguments must be serializable: " + error.message));
return started;
}
inFlight.add(id);
started = callHost("agent", payload)
.then((json) => JSON.parse(json))
.finally(() => inFlight.delete(id));
}
return started;
};
return Object.freeze({
then(resolve, reject) {
return begin().then(resolve, reject);
},
catch(reject) {
return begin().catch(reject);
},
finally(callback) {
return begin().finally(callback);
},
get [Symbol.toStringTag]() {
return "Promise";
},
});
}
async function mapLimited(items, concurrency, invoke) {
const results = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (true) {
const index = next++;
if (index >= items.length) return;
results[index] = await invoke(items[index]);
}
});
await Promise.all(workers);
return results;
}
async function parallel(items, options = {}) {
if (!Array.isArray(items)) throw new Error("parallel() expects an array of zero-argument agent thunks");
const requested = options && typeof options.concurrency === "number"
? Math.floor(options.concurrency)
: 4;
if (!Number.isFinite(requested) || requested < 1) {
throw new Error("parallel(): concurrency must be a positive integer");
}
const concurrency = Math.min(4, requested);
return mapLimited(items, concurrency, (item) => {
if (typeof item !== "function") {
throw new Error("parallel() items must be zero-argument functions");
}
return item();
});
}
function phase(title) {
callHost("phase", JSON.stringify({ title: String(title) }));
}
const argsEnvelope = JSON.parse(globalThis.__argsJson);
const args = argsEnvelope.defined ? deepFreeze(argsEnvelope.value) : undefined;
delete globalThis.__argsJson;
const stringify = JSON.stringify;
function serializeResult(value) {
const seen = new WeakSet();
return stringify(value === undefined ? null : value, (_key, item) => {
if (typeof item === "bigint") return item.toString() + "n";
if (item && typeof item === "object") {
if (seen.has(item)) return "[circular]";
seen.add(item);
}
return item;
});
}
Object.defineProperties(globalThis, {
agent: { value: requestAgent, writable: false, configurable: false },
parallel: { value: parallel, writable: false, configurable: false },
phase: { value: phase, writable: false, configurable: false },
args: { value: args, writable: false, configurable: false },
__workflowCheck: {
value: Object.freeze(() => ({
unconsumed: unconsumed.size,
inFlight: inFlight.size,
})),
writable: false,
configurable: false,
},
__workflowSerialize: {
value: Object.freeze(serializeResult),
writable: false,
configurable: false,
},
});
})();
`;
let initialized = false;
let token;
const pendingAgents = new Map();
function send(message) {
sendIpc?.({ token, ...message });
}
function fail(error) {
const message = error instanceof Error ? error.message : String(error);
send({ kind: "error", error: message.slice(0, 16 * 1024) });
}
process.on("message", (message) => {
if (!message || typeof message !== "object") return;
if (!initialized) {
if (
message.kind !== "init" ||
typeof message.token !== "string" ||
typeof message.source !== "string" ||
typeof message.argsJson !== "string"
) {
process.exitCode = 1;
return;
}
initialized = true;
token = message.token;
run(message.source, message.argsJson);
return;
}
if (message.token !== token || message.kind !== "agentResult") return;
const pending = pendingAgents.get(message.id);
if (!pending) return;
pendingAgents.delete(message.id);
if (typeof message.resultJson === "string")
pending.resolve(message.resultJson);
else
pending.reject(
new Error(
typeof message.error === "string" ? message.error : "Agent IPC failed",
),
);
});
function run(source, argsJson) {
try {
const sandbox = Object.create(null);
sandbox.__argsJson = argsJson;
sandbox.__hostBridge = (kind, payloadJson) => {
if (kind === "phase") {
send({ kind: "phase", payloadJson });
return undefined;
}
if (kind !== "agent")
return Promise.reject(new Error("Unknown workflow operation"));
let id;
try {
id = JSON.parse(payloadJson).id;
} catch {
return Promise.reject(new Error("Invalid agent request"));
}
return new Promise((resolve, reject) => {
pendingAgents.set(id, { resolve, reject });
send({ kind: "agent", payloadJson });
});
};
const context = vm.createContext(sandbox, {
name: "pi-workflow",
codeGeneration: { strings: false, wasm: false },
});
new vm.Script(BOOTSTRAP, {
filename: "workflow-bootstrap.js",
}).runInContext(context, { timeout: 1000 });
const workflow = vm.compileFunction(
`"use strict";\nreturn (async function workflow() {\n${source}\n})();`,
["agent", "parallel", "phase", "args"],
{ filename: "workflow-script.js", parsingContext: context },
);
context.__workflowBody = workflow;
const invoke = `
(() => {
const workflowBody = globalThis.__workflowBody;
delete globalThis.__workflowBody;
globalThis.__workflowPromise = Promise.resolve(
workflowBody(agent, parallel, phase, args),
).then(async (value) => {
await Promise.resolve();
const pending = __workflowCheck();
if (pending.unconsumed > 0) {
throw new Error("Workflow created " + pending.unconsumed + " unawaited agent() call(s)");
}
if (pending.inFlight > 0) {
throw new Error("Workflow returned before " + pending.inFlight + " agent call(s) settled");
}
return __workflowSerialize(value);
});
})();
`;
new vm.Script(invoke, { filename: "workflow-invoke.js" }).runInContext(
context,
{ timeout: 1000 },
);
Promise.resolve(context.__workflowPromise)
.then((resultJson) => {
if (typeof resultJson !== "string")
throw new Error("Workflow result was not serializable");
send({ kind: "result", resultJson });
})
.catch(fail);
} catch (error) {
fail(error);
}
}
+135
View File
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { runWorkflowSandbox } from "./sandbox.ts";
function run(
source: string,
overrides: Partial<Parameters<typeof runWorkflowSandbox>[0]> = {},
) {
const abort = new AbortController();
return runWorkflowSandbox({
source,
args: undefined,
cwd: process.cwd(),
signal: abort.signal,
onAgent: async (prompt) => ({ ok: true, output: `reply:${prompt}` }),
onPhase: () => {},
...overrides,
});
}
test("sandbox exposes only workflow capabilities and validates results", async () => {
const phases: string[] = [];
const result = await run(
`
phase("Gather");
const replies = await parallel([
() => agent("one"),
() => agent("two"),
], { concurrency: 99 });
return {
replies: replies.map((reply) => reply.output),
processType: typeof process,
requireType: typeof require,
fetchType: typeof fetch,
};
`,
{ onPhase: (title) => phases.push(title) },
);
assert.deepEqual(result, {
replies: ["reply:one", "reply:two"],
processType: "undefined",
requireType: "undefined",
fetchType: "undefined",
});
assert.deepEqual(phases, ["Gather"]);
});
test("sandbox result serialization handles cycles and bigint", async () => {
const result = await run(`
const value = { count: 7n };
value.self = value;
return value;
`);
assert.deepEqual(result, { count: "7n", self: "[circular]" });
});
test("sandbox rejects unawaited agent calls", async () => {
let calls = 0;
await assert.rejects(
run(`agent("orphan"); return "done";`, {
onAgent: async () => {
calls++;
return { ok: true, output: "unexpected" };
},
}),
/unawaited agent/,
);
assert.equal(calls, 0);
});
test("sandbox source cannot escape the host accounting wrapper", async () => {
let calls = 0;
await assert.rejects(
run(
`}), agent("orphan"), Promise.resolve("bypass"); (async function () {`,
{
onAgent: async () => {
calls++;
return { ok: true, output: "unexpected" };
},
},
),
/unawaited agent/,
);
assert.equal(calls, 0);
});
test("sandbox VM still rejects non-yielding synchronous code", async () => {
await assert.rejects(run(`while (true) {}`), /timed out/);
});
test("workflow agent invocations have no per-request wall timer", async () => {
let signalAborted = false;
const result = await run(`return (await agent("delayed")).output;`, {
onAgent: async (_prompt, _options, signal) => {
await new Promise((resolve) => setTimeout(resolve, 30));
signalAborted = signal.aborted;
return { ok: true, output: "completed" };
},
});
assert.equal(result, "completed");
assert.equal(signalAborted, false);
});
test("workflow cancellation aborts a pending agent request", async () => {
const controller = new AbortController();
let startedResolve: (() => void) | undefined;
const started = new Promise<void>((resolve) => {
startedResolve = resolve;
});
let requestAborted = false;
const pending = run(`return await agent("pending");`, {
signal: controller.signal,
onAgent: async (_prompt, _options, signal) => {
startedResolve?.();
await new Promise<void>((resolve) => {
signal.addEventListener(
"abort",
() => {
requestAborted = true;
resolve();
},
{ once: true },
);
});
return { ok: false, output: "", error: "Agent was aborted" };
},
});
await started;
controller.abort(new Error("cancel fixture"));
await assert.rejects(pending, /Workflow was aborted/);
assert.equal(requestAborted, true);
});
+299
View File
@@ -0,0 +1,299 @@
import { randomBytes } from "node:crypto";
import { spawn, type ChildProcess } from "node:child_process";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { safeStringify, toSerializable } from "./serialization.ts";
const MAX_SOURCE_BYTES = 512 * 1024;
const MAX_ARGS_BYTES = 256 * 1024;
const MAX_RESULT_BYTES = 1024 * 1024;
const MAX_AGENT_MESSAGE_BYTES = 512 * 1024;
const MAX_AGENT_REQUESTS = 32;
export interface SandboxAgentOptions {
label?: unknown;
phase?: unknown;
schema?: unknown;
model?: unknown;
provider?: unknown;
effort?: unknown;
}
export interface SandboxAgentResult {
ok: boolean;
output: string;
structured?: unknown;
error?: string;
}
export interface RunWorkflowSandboxOptions {
source: string;
args: unknown;
cwd: string;
signal: AbortSignal;
onAgent: (
prompt: string,
options: SandboxAgentOptions,
signal: AbortSignal,
) => Promise<SandboxAgentResult>;
onPhase: (title: string) => void;
}
function byteLength(value: string) {
return Buffer.byteLength(value, "utf8");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function errorText(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function terminateChild(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
}, 1_000);
force.unref?.();
}
function sanitizeAgentOptions(value: unknown): SandboxAgentOptions {
if (!isRecord(value)) return {};
return {
...(value.label !== undefined ? { label: value.label } : {}),
...(value.phase !== undefined ? { phase: value.phase } : {}),
...(value.schema !== undefined ? { schema: value.schema } : {}),
...(value.model !== undefined ? { model: value.model } : {}),
...(value.provider !== undefined ? { provider: value.provider } : {}),
...(value.effort !== undefined ? { effort: value.effort } : {}),
};
}
/**
* Execute orchestration code in a separate, permission-restricted Node process.
* The child can only invoke the narrow agent/phase IPC protocol and is always
* terminated on completion, cancellation, or protocol failure. The workflow
* itself and its agent requests have no wall-clock deadline. Active requests
* are aborted only when the workflow is cancelled or the sandbox is cleaned up.
*/
export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
if (!process.allowedNodeEnvironmentFlags.has("--permission")) {
return Promise.reject(
new Error("This Node runtime cannot enforce workflow child permissions"),
);
}
if (byteLength(options.source) > MAX_SOURCE_BYTES) {
return Promise.reject(
new Error(`Workflow script exceeds the ${MAX_SOURCE_BYTES} byte limit`),
);
}
const argsJson = safeStringify(
{ defined: options.args !== undefined, value: options.args },
{ maxBytes: MAX_ARGS_BYTES, maxDepth: 16, maxNodes: 10_000 },
);
if (byteLength(argsJson) > MAX_ARGS_BYTES) {
return Promise.reject(new Error("Workflow args exceed the IPC limit"));
}
return new Promise<unknown>((resolve, reject) => {
const workerPath = fileURLToPath(
new URL("./sandbox-child.cjs", import.meta.url),
);
const child = spawn(
process.execPath,
[
"--permission",
`--allow-fs-read=${path.dirname(workerPath)}`,
"--max-old-space-size=128",
"--stack-size=2048",
workerPath,
],
{
cwd: options.cwd,
env: {
PATH: process.env.PATH ?? "",
NODE_NO_WARNINGS: "1",
},
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
);
const token = randomBytes(24).toString("hex");
const requestIds = new Set<number>();
const activeAgentRequests = new Map<number, AbortController>();
let requestCount = 0;
let finished = false;
const cleanup = () => {
for (const abortController of activeAgentRequests.values()) {
abortController.abort(new Error("Workflow stopped"));
}
activeAgentRequests.clear();
options.signal.removeEventListener("abort", onAbort);
child.removeAllListeners("message");
child.removeAllListeners("error");
child.removeAllListeners("exit");
terminateChild(child);
};
const finish = (error?: Error, value?: unknown) => {
if (finished) return;
finished = true;
cleanup();
if (error) reject(error);
else resolve(value);
};
const onAbort = () => finish(new Error("Workflow was aborted"));
options.signal.addEventListener("abort", onAbort, { once: true });
if (options.signal.aborted) {
onAbort();
return;
}
child.on("error", (error) => finish(error));
child.on("exit", (code, exitSignal) => {
if (!finished) {
finish(
new Error(
`Workflow sandbox exited before completion (${exitSignal ?? code ?? "unknown"})`,
),
);
}
});
child.on("message", (raw: unknown) => {
if (
!isRecord(raw) ||
raw.token !== token ||
typeof raw.kind !== "string"
) {
finish(new Error("Workflow sandbox sent an invalid IPC message"));
return;
}
if (raw.kind === "phase") {
if (
typeof raw.payloadJson !== "string" ||
byteLength(raw.payloadJson) > 4096
) {
finish(new Error("Workflow sandbox sent an invalid phase update"));
return;
}
try {
const payload: unknown = JSON.parse(raw.payloadJson);
if (!isRecord(payload) || typeof payload.title !== "string") {
throw new Error("invalid title");
}
options.onPhase(payload.title.slice(0, 160));
} catch {
finish(new Error("Workflow sandbox sent an invalid phase update"));
}
return;
}
if (raw.kind === "agent") {
if (
typeof raw.payloadJson !== "string" ||
byteLength(raw.payloadJson) > MAX_AGENT_MESSAGE_BYTES
) {
finish(new Error("Workflow sandbox sent an oversized agent request"));
return;
}
let payload: unknown;
try {
payload = JSON.parse(raw.payloadJson);
} catch {
finish(new Error("Workflow sandbox sent malformed agent JSON"));
return;
}
if (
!isRecord(payload) ||
!Number.isSafeInteger(payload.id) ||
typeof payload.id !== "number" ||
payload.id < 1 ||
typeof payload.prompt !== "string" ||
payload.prompt.length > 100_000 ||
!isRecord(payload.options)
) {
finish(new Error("Workflow sandbox sent an invalid agent request"));
return;
}
if (requestIds.has(payload.id) || ++requestCount > MAX_AGENT_REQUESTS) {
finish(
new Error("Workflow sandbox exceeded its agent request budget"),
);
return;
}
requestIds.add(payload.id);
const id = payload.id;
const abortController = new AbortController();
const sendResult = (result: SandboxAgentResult) => {
if (!activeAgentRequests.delete(id)) return;
if (finished || !child.connected) return;
const normalized = toSerializable(result, {
maxDepth: 16,
maxNodes: 10_000,
maxStringBytes: 128 * 1024,
});
let resultJson = JSON.stringify(normalized);
if (byteLength(resultJson) > MAX_AGENT_MESSAGE_BYTES) {
resultJson = JSON.stringify({
ok: false,
output: "",
error: "Agent result exceeded the workflow IPC output limit",
});
}
child.send({ token, kind: "agentResult", id, resultJson });
};
activeAgentRequests.set(id, abortController);
void options
.onAgent(
payload.prompt,
sanitizeAgentOptions(payload.options),
abortController.signal,
)
.then(sendResult)
.catch((error) =>
sendResult({ ok: false, output: "", error: errorText(error) }),
);
return;
}
if (raw.kind === "result") {
if (
typeof raw.resultJson !== "string" ||
byteLength(raw.resultJson) > MAX_RESULT_BYTES
) {
finish(new Error("Workflow result exceeded the IPC limit"));
return;
}
try {
const normalized = toSerializable(JSON.parse(raw.resultJson));
finish(undefined, JSON.parse(JSON.stringify(normalized)));
} catch (error) {
finish(
new Error(`Workflow returned invalid JSON: ${errorText(error)}`),
);
}
return;
}
if (raw.kind === "error" && typeof raw.error === "string") {
finish(new Error(raw.error.slice(0, 16 * 1024)));
return;
}
finish(new Error("Workflow sandbox sent an unknown IPC message"));
});
child.send(
{
kind: "init",
token,
source: options.source,
argsJson,
},
(error) => {
if (error) finish(error);
},
);
});
}
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { safeStringify, writeFileAtomic } from "./serialization.ts";
test("safeStringify handles cycles, bigint, depth, and size", () => {
const value: Record<string, unknown> = {
bigint: 42n,
nested: { deeper: { deepest: true } },
large: "x".repeat(20_000),
};
value.self = value;
const text = safeStringify(value, {
maxBytes: 2_048,
maxDepth: 2,
maxStringBytes: 512,
});
assert.ok(Buffer.byteLength(text, "utf8") <= 2_048);
const parsed: unknown = JSON.parse(text);
assert.ok(parsed && typeof parsed === "object");
assert.match(text, /42n/);
assert.match(text, /circular/);
assert.match(text, /truncated/);
});
test("atomic writes leave complete readable content", () => {
const directory = mkdtempSync(join(tmpdir(), "pi-workflow-test-"));
try {
const file = join(directory, "artifact.json");
writeFileAtomic(file, '{"value":1}');
writeFileAtomic(file, '{"value":2}');
assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), { value: 2 });
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+162
View File
@@ -0,0 +1,162 @@
import * as fs from "node:fs";
import * as path from "node:path";
export interface SerializationOptions {
maxBytes?: number;
maxDepth?: number;
maxNodes?: number;
maxStringBytes?: number;
}
const DEFAULT_MAX_BYTES = 1024 * 1024;
const DEFAULT_MAX_DEPTH = 16;
const DEFAULT_MAX_NODES = 20_000;
const DEFAULT_MAX_STRING_BYTES = 64 * 1024;
function byteLength(value: string) {
return Buffer.byteLength(value, "utf8");
}
export function truncateUtf8(value: string, maxBytes: number) {
if (maxBytes <= 0) return "";
if (byteLength(value) <= maxBytes) return value;
const buffer = Buffer.from(value, "utf8");
let end = Math.min(maxBytes, buffer.length);
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
return buffer.subarray(0, end).toString("utf8");
}
/**
* Normalize arbitrary values to inert JSON data. Cycles, bigint, non-finite
* numbers, deep trees, throwing properties, and very large strings are all
* represented explicitly instead of making artifact persistence fail.
*/
export function toSerializable(
value: unknown,
options: SerializationOptions = {},
): unknown {
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
const maxStringBytes = options.maxStringBytes ?? DEFAULT_MAX_STRING_BYTES;
const seen = new WeakMap<object, string>();
let nodes = 0;
const visit = (
current: unknown,
depth: number,
location: string,
): unknown => {
nodes++;
if (nodes > maxNodes) return "[truncated: node limit]";
if (depth > maxDepth) return "[truncated: depth limit]";
if (current === null || typeof current === "boolean") return current;
if (typeof current === "string") {
if (byteLength(current) <= maxStringBytes) return current;
return `${truncateUtf8(current, maxStringBytes)}\n[truncated: string limit]`;
}
if (typeof current === "number") {
return Number.isFinite(current)
? current
: `[number: ${String(current)}]`;
}
if (typeof current === "bigint") return `${current.toString()}n`;
if (typeof current === "undefined") return "[undefined]";
if (typeof current === "symbol")
return `[symbol: ${current.description ?? ""}]`;
if (typeof current === "function")
return `[function: ${current.name || "anonymous"}]`;
if (typeof current !== "object") return String(current);
const prior = seen.get(current);
if (prior) return `[circular: ${prior}]`;
seen.set(current, location);
if (Array.isArray(current)) {
return current.map((item, index) =>
visit(item, depth + 1, `${location}[${index}]`),
);
}
if (current instanceof Date) {
return Number.isNaN(current.getTime())
? "[date: invalid]"
: current.toISOString();
}
if (current instanceof Error) {
return {
name: current.name,
message: current.message,
...(current.stack
? { stack: truncateUtf8(current.stack, 16 * 1024) }
: {}),
};
}
const result: Record<string, unknown> = Object.create(null);
let keys: string[];
try {
keys = Object.keys(current);
} catch (error) {
return `[unreadable object: ${error instanceof Error ? error.message : String(error)}]`;
}
for (const key of keys) {
try {
result[key] = visit(
(current as Record<string, unknown>)[key],
depth + 1,
`${location}.${key}`,
);
} catch (error) {
result[key] =
`[unreadable property: ${error instanceof Error ? error.message : String(error)}]`;
}
}
return result;
};
return visit(value, 0, "$root");
}
/** Serialize to valid JSON no larger than the requested cap. */
export function safeStringify(
value: unknown,
options: SerializationOptions = {},
) {
const maxBytes = Math.max(256, options.maxBytes ?? DEFAULT_MAX_BYTES);
const normalized = toSerializable(value, options);
const serialized = JSON.stringify(normalized, null, 2) ?? "null";
if (byteLength(serialized) <= maxBytes) return serialized;
let previewBytes = Math.max(32, Math.floor(maxBytes / 3));
while (previewBytes > 0) {
const fallback = JSON.stringify(
{
truncated: true,
reason: `serialized value exceeded ${maxBytes} bytes`,
preview: truncateUtf8(serialized, previewBytes),
},
null,
2,
);
if (byteLength(fallback) <= maxBytes) return fallback;
previewBytes = Math.floor(previewBytes / 2);
}
return JSON.stringify({ truncated: true });
}
/** Durable same-directory replace: readers see either the old or new file. */
export function writeFileAtomic(filePath: string, content: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
fs.renameSync(temporary, filePath);
} catch (error) {
try {
fs.unlinkSync(temporary);
} catch {
// The original write error is more useful.
}
throw error;
}
}
+4934 -1343
View File
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -11,7 +11,11 @@
"node": ">=22.19.0"
},
"scripts": {
"typecheck": "tsc --noEmit"
"prepare": "effect-tsgo patch",
"typecheck": "tsc --noEmit",
"test": "npm run test:runner && node --test --experimental-strip-types extensions/firecrawl-search/index.test.ts extensions/shared/*.test.ts extensions/subagents/by-the-way.test.ts extensions/subagents/context-usage.test.ts extensions/subagents/manager.test.ts extensions/subagents/result-delivery.test.ts extensions/subagents/takeover.test.ts extensions/workflows/*.test.ts && vitest run extensions/file-search/index.spec.ts",
"test:runner": "python -m unittest scripts/test_orca_nono_profile.py",
"test:live": "node --test --experimental-strip-types extensions/subagents/claude.test.ts extensions/subagents/codex.test.ts"
},
"pi": {
"extensions": [
@@ -22,11 +26,31 @@
]
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"devDependencies": {
"@earendil-works/pi-ai": "0.84.2",
"@earendil-works/pi-coding-agent": "^0.84.2",
"@earendil-works/pi-tui": "0.84.2",
"@effect/tsgo": "0.24.3",
"@effect/vitest": "4.0.0-beta.101",
"@types/node": "^22.0.0",
"typescript": "^5.9.0"
"typebox": "1.3.17",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.218",
"@effect/platform-node": "4.0.0-beta.101",
"@effect/platform-node-shared": "4.0.0-beta.101",
"acorn": "8.18.0",
"effect": "4.0.0-beta.101",
"firecrawl": "4.30.1"
},
"overrides": {
"@effect/platform-node-shared": "4.0.0-beta.101"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "pi-orca-local",
"meta": {
"name": "pi-harness-tools",
"description": "Harness-tools Pi sandbox for Orca with global skill and extension installation"
},
"filesystem": {
"allow": [
"$HOME/.agents/skills"
],
"read": [
"$HOME/.config/orca/linux-orca-cli-shim"
]
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"extends": "nolabs-ai/pi",
"meta": {
"name": "pi-local",
"description": "Restrictive local Pi sandbox with mediated network and Unix sockets"
},
"groups": {
"include": []
},
"workdir": {
"access": "readwrite"
},
"filesystem": {
"allow": [
"$WORKDIR",
"$TMPDIR"
],
"read": [
"$HOME/.bun",
"/srv/data/code/harness-tools/extensions/effort-controls",
"/srv/data/code/harness-tools/extensions/update"
],
"unix_socket": [
"/run/systemd/resolve/io.systemd.Resolve"
]
},
"linux": {
"af_unix_mediation": "pathname"
},
"network": {
"block": false,
"allow_domain": [
"chatgpt.com",
"api.openai.com",
"auth.openai.com",
"api.anthropic.com",
"generativelanguage.googleapis.com",
"git.jb.codes",
"github.com",
"api.github.com",
"codeload.github.com",
"raw.githubusercontent.com",
"objects.githubusercontent.com",
"registry.npmjs.org",
"pypi.org",
"files.pythonhosted.org",
"crates.io",
"index.crates.io",
"static.crates.io",
"static.rust-lang.org",
"nodejs.org"
]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "pi-local",
"meta": {
"name": "pi-orca-local",
"description": "Restrictive local Pi sandbox with Orca hook integration"
},
"filesystem": {
"read_file": [
"/home/jb/.config/orca/agent-hooks/endpoint.env"
]
},
"network": {
"allow_domain": [
"127.0.0.1"
]
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "pi-orca-local",
"meta": {
"name": "pi-work",
"description": "Work Pi sandbox with Orca hooks and work Git integration"
},
"filesystem": {
"read_file": [
"/srv/work/.config/git/work-ai-ignore"
]
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"defaultProfile": "pi-orca-local",
"projectRoots": {
"/srv/work": "pi-work",
"/srv/data/code/harness-tools": "pi-harness-tools"
},
"projects": {}
}
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Manage trusted project-to-nono-profile mappings for Orca-launched Pi."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any
MAP_PATH = Path("/srv/data/code/harness-tools/profiles/orca/project-nono-profiles.json")
DEFAULT_PROFILE = "pi-orca-local"
def git_output(path: Path, *args: str) -> str | None:
result = subprocess.run(
["git", "-C", str(path), *args],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
value = result.stdout.strip()
return value or None
def canonical_project_root(path: Path) -> Path:
candidate = path.expanduser().resolve()
common_dir = git_output(candidate, "rev-parse", "--path-format=absolute", "--git-common-dir")
if common_dir:
common_path = Path(common_dir).resolve()
if common_path.name == ".git":
return common_path.parent
top_level = git_output(candidate, "rev-parse", "--show-toplevel")
if top_level:
return Path(top_level).resolve()
return candidate
def load_map() -> dict[str, Any]:
try:
contents = MAP_PATH.read_text(encoding="utf-8")
data = json.loads(contents)
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"cannot load profile map: {error}") from error
if not isinstance(data, dict):
raise ValueError("profile map must be a JSON object")
projects = data.get("projects")
if not isinstance(projects, dict):
raise ValueError("profile map projects must be a JSON object")
project_roots = data.get("projectRoots", {})
if not isinstance(project_roots, dict):
raise ValueError("profile map projectRoots must be a JSON object")
for root, profile in project_roots.items():
if not isinstance(root, str) or not Path(root).is_absolute():
raise ValueError("projectRoots keys must be absolute paths")
if not isinstance(profile, str) or not profile:
raise ValueError("projectRoots values must be non-empty profile names")
default = data.get("defaultProfile", DEFAULT_PROFILE)
if not isinstance(default, str) or not default:
raise ValueError("defaultProfile must be a non-empty string")
return data
def save_map(data: dict[str, Any]) -> None:
MAP_PATH.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=".project-nono-profiles.", dir=MAP_PATH.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
temporary.chmod(0o600)
os.replace(temporary, MAP_PATH)
finally:
temporary.unlink(missing_ok=True)
def profile_exists(profile: str) -> bool:
result = subprocess.run(
["nono", "profile", "show", profile],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return result.returncode == 0
def resolve_profile(
data: dict[str, Any], project: Path, launch_path: Path | None = None
) -> tuple[str, str]:
projects = data["projects"]
mapped = projects.get(str(project))
if isinstance(mapped, str) and mapped:
return mapped, "project"
route_candidates = {project}
if launch_path is not None:
route_candidates.add(launch_path)
matching_roots: list[tuple[Path, str]] = []
for root_value, profile in data.get("projectRoots", {}).items():
root = Path(root_value)
if any(candidate == root or root in candidate.parents for candidate in route_candidates):
matching_roots.append((root, profile))
if matching_roots:
_root, profile = max(matching_roots, key=lambda match: len(match[0].parts))
return profile, "project-root"
return data.get("defaultProfile", DEFAULT_PROFILE), "default"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
for command in ("resolve", "show", "unset", "unset-root"):
subparser = subparsers.add_parser(command)
subparser.add_argument("path", nargs="?", default=os.getcwd())
for command in ("set", "set-root"):
set_parser = subparsers.add_parser(command)
set_parser.add_argument("profile")
set_parser.add_argument("path", nargs="?", default=os.getcwd())
subparsers.add_parser("list")
args = parser.parse_args()
try:
data = load_map()
except ValueError as error:
parser.error(str(error))
if args.command == "list":
print(json.dumps(data, indent=2, sort_keys=True))
return 0
launch_path = Path(args.path).expanduser().resolve()
project = canonical_project_root(launch_path)
if args.command == "resolve":
profile, _source = resolve_profile(data, project, launch_path)
print(profile)
return 0
if args.command == "show":
profile, source = resolve_profile(data, project, launch_path)
print(
json.dumps(
{
"launchPath": str(launch_path),
"projectRoot": str(project),
"profile": profile,
"source": source,
},
indent=2,
sort_keys=True,
)
)
return 0
if args.command in ("set", "set-root"):
if not profile_exists(args.profile):
parser.error(f"nono profile does not exist: {args.profile}")
mapping_key = "projects" if args.command == "set" else "projectRoots"
mapping_path = project if args.command == "set" else launch_path
data.setdefault(mapping_key, {})[str(mapping_path)] = args.profile
save_map(data)
print(f"{mapping_path} -> {args.profile}")
return 0
if args.command in ("unset", "unset-root"):
mapping_key = "projects" if args.command == "unset" else "projectRoots"
mapping_path = project if args.command == "unset" else launch_path
removed = data.setdefault(mapping_key, {}).pop(str(mapping_path), None)
save_map(data)
if removed is None:
print(f"no mapping for {mapping_path}")
else:
print(f"removed {mapping_path} -> {removed}")
return 0
raise AssertionError(f"unhandled command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env fish
# Select a nono profile from a trusted canonical-project map, then launch Pi in
# its own bounded cgroup. Unknown or invalid projects fail closed.
set -l resolver /home/jb/.local/bin/orca-nono-profile
if not test -x "$resolver"
printf 'pi-orca: profile resolver is unavailable: %s\n' "$resolver" >&2
exit 1
end
set -l profile ("$resolver" resolve "$PWD" 2>/dev/null)
if test $status -ne 0; or test -z "$profile"
printf 'pi-orca: could not resolve a trusted profile for %s\n' "$PWD" >&2
exit 1
end
if not /usr/bin/nono profile show "$profile" >/dev/null 2>&1
printf 'pi-orca: resolved profile is unavailable: %s\n' "$profile" >&2
exit 1
end
set -l node_executable /usr/bin/node
set -l pi_agent_dir /home/jb/.pi/agent
# Native Node addons are ABI-specific. Keep the work profile's Node 22 package
# tree separate from the default Node 26 package tree.
if test "$profile" = pi-work
set node_executable /home/jb/.local/share/fnm/node-versions/v22.23.2/installation/bin/node
set pi_agent_dir /home/jb/.pi/profiles/cyone
end
if not test -x "$node_executable"
printf 'pi-orca: Node runtime is unavailable: %s\n' "$node_executable" >&2
exit 1
end
if not test -d "$pi_agent_dir"
printf 'pi-orca: Pi agent directory is unavailable: %s\n' "$pi_agent_dir" >&2
exit 1
end
set -lx ORCA_NONO_PROFILE "$profile"
set -lx PI_CODING_AGENT_DIR "$pi_agent_dir"
exec /usr/bin/systemd-run \
--user \
--scope \
--collect \
--quiet \
--slice=orca-agents.slice \
--property=CPUQuota=1600% \
--property=MemoryHigh=48G \
--property=MemoryMax=64G \
--property=MemorySwapMax=8G \
--property=TasksMax=8192 \
--property=OOMPolicy=stop \
-- /usr/bin/nono run --profile "$profile" -- "$node_executable" /home/jb/.bun/bin/pi $argv
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import importlib.util
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).with_name("orca-nono-profile.py")
SPEC = importlib.util.spec_from_file_location("orca_nono_profile", MODULE_PATH)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"Could not load runner resolver from {MODULE_PATH}")
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class ResolveProfileTests(unittest.TestCase):
def setUp(self) -> None:
self.mapping = {
"defaultProfile": "pi-orca-local",
"projectRoots": {
"/srv/work": "pi-work",
"/srv/work/special": "pi-special",
},
"projects": {
"/srv/work/special/override": "pi-override",
},
}
def test_exact_project_mapping_wins(self) -> None:
self.assertEqual(
MODULE.resolve_profile(self.mapping, Path("/srv/work/special/override")),
("pi-override", "project"),
)
def test_longest_project_root_wins(self) -> None:
self.assertEqual(
MODULE.resolve_profile(self.mapping, Path("/srv/work/special/repository")),
("pi-special", "project-root"),
)
def test_project_root_matches_descendants(self) -> None:
self.assertEqual(
MODULE.resolve_profile(self.mapping, Path("/srv/work/code/repository")),
("pi-work", "project-root"),
)
def test_linked_worktree_uses_its_launch_path_root(self) -> None:
self.assertEqual(
MODULE.resolve_profile(
self.mapping,
Path("/home/jb/code/main-checkout"),
Path("/srv/work/worktrees/feature"),
),
("pi-work", "project-root"),
)
def test_project_root_does_not_match_similar_prefix(self) -> None:
self.assertEqual(
MODULE.resolve_profile(self.mapping, Path("/srv/worker/repository")),
("pi-orca-local", "default"),
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: pptx
description: Presentation creation, editing, and analysis. When OpenCode needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks
description: Presentation creation, editing, and analysis. When the harness needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks
license: Proprietary. LICENSE.txt has complete terms
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: xlsx
description: Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When OpenCode needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas
description: Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When the harness needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas
license: Proprietary. LICENSE.txt has complete terms
---
+6 -1
View File
@@ -11,6 +11,7 @@ Scaffold the per-repo configuration that the design and develop skills assume:
- **Issue tracker**: where issues live (Gitea by default; local markdown is also supported out of the box)
- **Triage labels**: the strings used for the five canonical triage roles
- **Domain docs**: where `CONTEXT.md` and ADRs live, and the consumer rules for reading them
- **Sub-agents**: a basic instruction to delegate through Paseo's CLI
This is a prompt-driven skill, not a deterministic script. Explore, present what you found, confirm with the user, then write.
@@ -65,7 +66,7 @@ Offer **multi-context** (a root `CONTEXT-MAP.md` pointing to per-context `CONTEX
Show the user a draft of:
- The `## Agent skills` block to add to whichever of `CLAUDE.md` / `AGENTS.md` is being edited (see step 4 for selection rules)
- The `## Agent skills` block to add to whichever of `CLAUDE.md` / `AGENTS.md` is being edited (see step 4 for selection rules), including the Paseo sub-agent instructions
- The contents of `docs/agents/issue-tracker.md`, `docs/agents/domain.md`, and `docs/agents/triage-labels.md` (the last only when `triage` is installed)
Let them edit before writing.
@@ -98,6 +99,10 @@ The block:
### Domain docs
[one-line summary of layout: "single-context" or "multi-context"]. See `docs/agents/domain.md`.
### Sub-agents
Delegate sub-agent work through the Paseo CLI. Start work asynchronously in the current workspace with `paseo run --background --title "<task>" "<self-contained prompt>"`; use `--new-workspace worktree` plus the appropriate worktree mode when the task needs isolated edits. Use `paseo send <agent-id> "<follow-up>"` for follow-ups. Let Paseo's completion notification report the result instead of polling. Run `paseo --help`, `paseo run --help`, or `paseo provider ls` when launch details or provider availability are unclear.
```
Include the `### Triage labels` sub-block, and write `docs/agents/triage-labels.md`, only when `triage` is installed and Section B ran. When it isn't, both are omitted.
+119
View File
@@ -0,0 +1,119 @@
---
name: nono-profiles
description: "Author or revise repository-managed nono sandbox profiles. Use for nono profile changes, additional repository or client-directory access, filesystem grants, Internet access tiers and domain allowlists, Unix sockets, profile inheritance, validation, or troubleshooting profile permissions in this environment."
---
# Nono Profiles
Author reviewed nono profiles from this repository, using `profiles/nono/pi-local.json` as the local starting point and `~/.config/nono/profiles/` only as the activation location.
## Process
### 1. Inspect the live policy
Locate the repository root and read:
- `profiles/nono/pi-local.json`
- the requested target profile, when it exists
- the target's entry under `~/.config/nono/profiles/`, including its resolved symlink target
- the installed parent profile named by `extends`
Run `nono --version` and consult `nono profile --help` when command behavior is uncertain. Read [policy-fields.md](references/policy-fields.md) before changing inheritance, filesystem, network, credentials, or Unix-socket policy.
Summarize the current parent, working-directory policy, extra filesystem grants, network mode, URL-opening policy, service-launch policy, and active-link state. Treat inherited grants as additive unless the installed nono documentation and validator prove otherwise.
Completion criterion: account for every effective capability relevant to the requested change, including inherited capabilities.
### 2. Interview for the intended capability
Treat details already stated by the user as answers. Collect only unresolved choices with `ask_user`; use a short follow-up when exact paths or domains depend on an earlier answer.
Always resolve these decisions before drafting:
1. **Profile strategy** — ask whether to edit `pi-local` or create a named derived profile. Ask for a profile name and description when deriving.
2. **Working set** — ask whether access covers only the launch working directory, a list of exact repositories/directories, or a shared parent containing multiple repositories.
3. **Access mode** — collect read-only, write-only, or read/write access for every additional path, then map those labels to the profile fields. Recommend exact repositories over a broad client or source-code parent. State the broader blast radius before accepting a shared parent.
4. **Internet tier** — offer:
- **Offline**: block Internet access, URL opening, and service launching.
- **Allowlisted**: enable networking only for an explicit hostname list; confirm URL-opening origins separately.
- **Open**: enable unrestricted networking; confirm URL-opening origins separately.
5. **Allowlisted hosts** — for allowlisted mode, identify the operations the project needs, then collect exact hostnames for code hosting, package registries, APIs, model providers, and artifact/CDN downloads. Distinguish required hosts from convenience hosts.
Ask about exact files, credential routes, and Unix sockets only when the request or inspected project indicates they are needed. Keep filesystem, Internet, credential, and Unix-socket decisions separate.
Completion criterion: map every requested capability to an exact path, hostname, credential route, or socket and an explicit access level.
### 3. Choose a safe parent
Use one `extends` parent; build a transitive chain rather than multiple inheritance.
Apply these inheritance rules:
- Extend `pi-local` when its filesystem grants, existing domain allowlist, URL-opening origins, and service-launch policy are acceptable as a minimum.
- Extend `nolabs-ai/pi` for offline mode. Set `network.block` to `true`, clear `open_urls.allow_origins`, disable `open_urls.allow_localhost`, and set `allow_launch_services` to `false`. Do not extend allowlisted `pi-local`: inherited proxy domains conflict with blocked networking.
- Extend `nolabs-ai/pi` rather than `pi-local` when an allowlisted child needs fewer domains than `pi-local`, or when creating an open-network profile. Reproduce only the required local filesystem/socket policy.
- Edit `pi-local` only after showing how the change affects every future project launched with it.
An inherited broad capability cannot reliably be narrowed by an empty child array. Choose a restrictive parent instead.
### 4. Draft the policy
Create a candidate at `~/.config/nono/profile-drafts/<name>.json`. Keep the repository target at `profiles/nono/<name>.json`.
Apply least privilege:
- Use `workdir.access` for the runtime working directory.
- Use directory fields for directory trees and exact-file fields for one file.
- Prefer read-only grants for source or reference trees that need no mutation.
- Use read/write grants only for repositories, caches, build output, or state that must change.
- Use canonical resolved paths for symlinked resources where practical.
- Keep hostname allowlists, URL-opening origins, service launching, and Unix socket allowlists independent.
- Store hostnames, never URL paths, in `network.allow_domain`.
Preserve unrelated keys and user formatting when editing an existing profile.
### 5. Validate and review
Run:
```bash
nono profile validate --draft <name> --strict
nono profile show ~/.config/nono/profile-drafts/<name>.json
```
Show:
- the proposed profile or focused diff
- the resolved policy and selected inherited baseline
- every added or removed filesystem capability
- the selected Internet tier and all allowed domains
- URL-opening origins and service-launch permission
- any credential routes or Unix sockets
- whether the repository target is already active through a symlink
Request explicit approval with `ask_user` before copying the validated draft into `profiles/nono/<name>.json`.
Completion criterion: validation passes and the user approves the exact policy diff.
### 6. Install without bypassing review
After approval, copy the validated candidate into the repository target, then run `nono profile validate --strict profiles/nono/<name>.json`. Do not write directly into `~/.config/nono/profiles/` from an agent running inside nono.
When the active profile already symlinks to the repository target, report that the next nono launch will use the change. Otherwise, provide this outside-sandbox activation step with resolved paths:
```bash
ln -sfn /absolute/repo/profiles/nono/<name>.json ~/.config/nono/profiles/<name>.json
nono profile validate <name>
```
Use `nono profile promote <name>` only when the user wants nono to install the draft as a regular active profile instead of preserving the repository symlink convention. When replacing an existing regular profile, create the draft through nono's edit workflow or supply the required `<name>.base` SHA-256 guard described in [policy-fields.md](references/policy-fields.md); never bypass concurrent-change protection.
For denied paths in the current sandbox, diagnose the concrete operation with:
```bash
nono why --self --path /resolved/path --op read
```
Use `write` or `readwrite` for the corresponding operation. Explain that policy changes apply to a newly launched sandbox, not the current process.
Completion criterion: the repository profile is approved and valid, activation instructions preserve the chosen repository-link convention, and remaining restart steps are explicit.
@@ -0,0 +1,139 @@
# Nono policy fields in this environment
Consult this reference when mapping interview answers into a profile. Treat `nono profile validate` and the installed parent profiles as authoritative for the installed nono version.
## Inheritance
Use a single string-valued `extends`. Local profiles may extend `pi-local`, registry profiles such as `nolabs-ai/pi`, or another available profile. Inheritance is transitive.
Treat included groups and list-valued capabilities as additive. An empty child list does not establish revocation. To create a narrower policy, derive from a narrower parent and add only the required capabilities.
## Runtime working directory
`workdir.access: "readwrite"` grants the directory from which nono launches the process. It does not mean the directory containing the profile.
Use the working-directory grant for a single active repository. Add explicit filesystem paths when one session needs several repositories or client directories.
## Filesystem access
Use directory fields for recursive directory-tree access:
| Field | Capability |
| --- | --- |
| `filesystem.read` | Read a directory tree |
| `filesystem.write` | Write a directory tree |
| `filesystem.allow` | Read and write a directory tree |
Use exact-file fields for one file:
| Field | Capability |
| --- | --- |
| `filesystem.read_file` | Read one file |
| `filesystem.write_file` | Write one file |
| `filesystem.allow_file` | Read and write one file |
Prefer enumerating exact repositories. Grant a shared parent only after confirming that every current and future child directory may receive the selected capability.
Resolve symlinks before reasoning about access. nono enforcement and audit output may use canonical targets.
## Internet tiers
### Offline
Derive from `nolabs-ai/pi`, not allowlisted `pi-local`. Inherited proxy domains conflict with blocked networking. Clear the inherited URL-opening and service-launch capabilities too:
```json
{
"extends": "nolabs-ai/pi",
"network": {
"block": true
},
"open_urls": {
"allow_origins": [],
"allow_localhost": false
},
"allow_launch_services": false
}
```
Validate the resolved policy and confirm that it reports `network blocked` with no approved URL origins.
### Allowlisted
Set networking enabled with a non-empty hostname list:
```json
{
"network": {
"block": false,
"allow_domain": [
"example.internal",
"registry.npmjs.org"
]
}
}
```
Use hostnames only. URL paths belong in credential endpoint rules, not `allow_domain`.
Inherited hostname lists may remain effective. Derive from `nolabs-ai/pi` when the intended list must be smaller than `pi-local`'s list.
Collect domains by operation rather than guessing:
- source fetch and issue API
- package metadata and package artifacts/CDNs
- language toolchains and binary downloads
- model or application APIs
- deployment, telemetry, or error reporting
### Open
Set networking enabled without a domain allowlist inherited from the chosen parent. Derive from `nolabs-ai/pi`, not an allowlisted `pi-local`, unless installed documentation proves the inherited allowlist can be removed.
```json
{
"network": {
"block": false
}
}
```
State that open mode allows destinations beyond those currently known to the project.
## URL opening and service launching
`open_urls.allow_origins` controls origins that sandboxed tools may ask the host to open. `open_urls.allow_localhost` controls localhost URL opening. These capabilities remain separate from direct Internet access.
`allow_launch_services` controls service launching on supported platforms. Set it to `false` for offline profiles and inspect the inherited value for other tiers.
The installed `nolabs-ai/pi` parent enables selected OpenAI, Anthropic, and GitHub origins, localhost URL opening, and service launching. Child profiles must review those inherited settings rather than assuming `network.block` or `allow_domain` controls them.
## Unix sockets
`filesystem.unix_socket` controls pathname-mediated Unix sockets independently of Internet networking. Add only exact socket paths required by local services. Diagnose socket denials separately from domain denials.
## Credentials
Treat a credential route as a separate capability from reaching its domain. Confirm both the route and endpoint restrictions. Prefer nono-mediated credential routes over exposing real API keys inside the sandbox.
## Local sources of truth
Read these when behavior or schema details are uncertain:
- `profiles/nono/pi-local.json`
- `~/.config/nono/packages/nolabs-ai/pi/profiles/pi.json`
- `~/.config/nono/packages/nolabs-ai/pi/README.md`
- `~/.config/nono/packages/nolabs-ai/pi/skills/nono-sandbox/SKILL.md`
- `/usr/share/doc/nono-ai-bin/README.md`
- `nono profile --help`
Stage candidates under `~/.config/nono/profile-drafts/`, then inspect them with:
```bash
nono profile validate --draft <name> --strict
nono profile show ~/.config/nono/profile-drafts/<name>.json
```
Validate the repository copy with `nono profile validate --strict profiles/nono/<name>.json`.
When promoting over an existing regular active profile, nono requires `~/.config/nono/profile-drafts/<name>.base` to contain the active profile's lowercase SHA-256 digest. Prefer nono's profile-edit workflow to create this guard. Preserve the repository symlink convention instead of promotion when the profile belongs in this repository.
+69
View File
@@ -0,0 +1,69 @@
---
name: subagents
description: invoke this skill when the user asks you to use subagents
---
# Subagents
Each subagent is headless, has its own context window, cannot see the parent conversation, cannot ask the user, and cannot spawn subagents or workflows. Give every child a self-contained prompt with paths, constraints, and the expected report.
## Pi Harness
**Harness:** `pi`
**Prompt nicknames:** “pi”, “pi agent”, “pi subagent”
**Best default:** Use when the user does not request another harness. It inherits the parent model and thinking level when `model` or `reasoning_effort` is omitted.
Do not use models from the Anthropic provider even if one appears in the model list.
Pi can use any model shown by `pi --list-models`. Prefer `provider/model-id`; a bare model id only works when unambiguous. Common picks in this environment:
| Model | Recommended effort |
| -------------------------------- | ------------------ |
| inherited parent model (default) | inherited |
| `openai-codex/gpt-5.6-sol` | `high` |
| `openai-codex/gpt-5.6-terra` | `high` |
| `opencode/claude-fable-5` | `medium` |
**Thinking budgets:** `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. These map directly to pi thinking levels.
## Claude Code Harness
**Harness:** `claude`
**Prompt nicknames:** “claude”, “Claude Code”, “claude agent”, “claude subagent”, "cc"
**Best default:** use the latest fable model on high reasoning. Do not default to anything else, if the user does not specify, use fable.
| Model hint | Model | Recommended effort |
| ---------- | ------------------- | ------------------ |
| `fable` | latest Claude Fable | `high` |
**Thinking budgets:** `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. The extension maps these to Claude thinking-token budgets: 0, 1,024, 4,096, 10,000, 16,000, 32,000, and 63,999 tokens respectively.
Requires Claude Code to be installed and authenticated.
## Codex Harness
**Harness:** `codex`
**Prompt nicknames:** “codex”, “Codex CLI”, “codex agent”, “codex subagent”
**Best default:** `gpt-5.6-sol` with `high` effort for coding work. Do not use anything other than sol unless the user specifically asks for it.
| Model | Recommended effort |
| --------------- | ------------------ |
| `gpt-5.6-sol` | `high` |
| `gpt-5.6-terra` | `high` |
| `gpt-5.6-luna` | `high` |
**Thinking budgets accepted by the extension:** `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Codex maps these to the nearest effort supported by the selected model; `off`/`minimal` become `minimal`, while `max` becomes the highest extension-supported Codex effort.
Requires the Codex CLI to be installed and authenticated.
## Spawn and Manage
Call `subagent_spawn` with a complete `prompt`, short `name`, chosen `harness`, and optional `working_dir`, `model`, and `reasoning_effort`. At most four subagents run concurrently.
- `subagent_check({ id })`: peek without blocking.
- `subagent_list()`: list all runs.
- `subagent_wait({ ids })`: block only when results are required to proceed.
- `subagent_cancel({ ids })`: stop runs while preserving partial transcripts.
- `/subagents`: inspect or take over a run interactively.
Results return automatically. After spawning, continue useful parent work instead of immediately waiting.
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Resource envelope for Orca-managed AI agent sessions
[Slice]
CPUQuota=3200%
MemoryAccounting=yes
MemoryHigh=112G
MemoryMax=128G
MemorySwapMax=16G
TasksAccounting=yes
TasksMax=16384