diff --git a/.env.example b/.env.example index 3542338..17cf427 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,8 @@ -# required -PROJECT_DIR=/abs/path/to/your/app +# required — workspace root holding every project dir plus .pi-global/. +# Mount as a Docker volume for restart-safe projects + registry. +WORKSPACE_DIR=/abs/path/to/workspace # optional (with defaults) -# SESSIONS_DIR=$PROJECT_DIR/data/sessions -# AGENTS_FILE=.pi/AGENTS.md # AGENT_SERVER_HOST=127.0.0.1 # AGENT_SERVER_PORT=4001 diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml new file mode 100644 index 0000000..a6b2b3c --- /dev/null +++ b/.github/workflows/contract.yml @@ -0,0 +1,83 @@ +# +# Contract gate for the agent-server OpenAPI surface. +# +# Two checks, both aimed at the one risk that matters here: pi (the upstream +# engine whose types we forward) drifting silently into our published contract. +# +# (1) Freshness — regenerate the committed contract artifacts +# (eventSchema.generated.json + openapi.json) and fail if the +# working tree changed. Catches "bumped pi / edited a route +# but forgot to regenerate + commit the contract". +# +# (2) Breaking — diff this PR's openapi.json against the base branch with +# oasdiff and fail on breaking changes. Turns silent drift +# into a reviewed, intentional event. +# +# The breaking-change check runs on pull_request because that's the only place +# it can actually *block* a merge (via branch protection). The freshness check +# also runs on push to main as a trunk safety net for direct pushes. +# +name: contract + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: contract-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # (1) The committed contract must be a faithful regeneration of the source. + freshness: + name: Contract artifacts are fresh + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Regenerate contract artifacts + # Order matters: the typia-generated event schema must be refreshed + # before the OpenAPI dump, which merges it in. + run: | + npm run gen:event-schema + npm run openapi + + - name: Verify committed artifacts are up to date + run: | + if ! git diff --exit-code -- openapi.json src/contract/eventSchema.generated.json; then + echo "::error::Contract artifacts are stale. Run 'npm run gen:event-schema && npm run openapi' and commit the result." + exit 1 + fi + + # (2) No breaking changes may reach main without review. + # breaking: + # name: No breaking contract changes + # # Only meaningful on PRs, where it can gate the merge against the base branch. + # if: github.event_name == 'pull_request' + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + + # # oasdiff reads the base spec straight from git; fetch the base branch. + # - run: git fetch --depth=1 origin ${{ github.base_ref }} + + # - name: Detect breaking changes (oasdiff) + # uses: oasdiff/oasdiff-action/breaking@v0.0.56 + # with: + # base: origin/${{ github.base_ref }}:openapi.json + # revision: HEAD:openapi.json + # # Fail only on definite breaking changes (ERR). Intentional breaks can + # # be acknowledged via a `.oasdiff.yaml` err-ignore entry + a contract + # # version bump in src/openapi.ts. + # fail-on: ERR diff --git a/.gitignore b/.gitignore index 94362eb..d6c1f3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,18 @@ node_modules/ dist/ +.gen/ .env .env.local *.log .DS_Store + +# IDE +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json + +# Docs +docs/misc/ + +# Agents +.pi/todos/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..4211b51 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,46 @@ +# agent-server pre-commit: shared formatting + a typed, in-sync contract. +# +# Always (fast, always relevant): +# 1. Biome format + lint (pi's conventions: tab/width-3, lineWidth 120). +# `--write` applies safe fixes; `--error-on-warnings` blocks on the rest. +# 2. Typecheck (tsc --noEmit) — fail fast on type errors. +# +# Only when the contract could have changed (a staged `src/` file or a +# dependency bump that might move pi's types): +# 3. Regenerate the typia SSE event schema, then the merged OpenAPI dump, +# and re-stage the artifacts. On unrelated commits this is skipped — the +# CI freshness gate (.github/workflows/contract.yml) remains the +# authoritative net for any deviation that slips through. +# +# Finally, re-stage everything that was already staged (Biome may have +# reformatted it). `set -e` aborts the commit on the first failing step. +set -e + +# Snapshot what the user staged before Biome rewrites files in place. +STAGED_FILES=$(git diff --cached --name-only) + +npm run check +npm run typecheck + +# Does this commit touch anything that could change the published contract? +contract_touched=0 +for file in $STAGED_FILES; do + case "$file" in + src/*.ts | src/**/*.ts | package.json | package-lock.json) + contract_touched=1 + break + ;; + esac +done + +if [ "$contract_touched" -eq 1 ]; then + echo "Contract-relevant change staged — regenerating openapi.json + event schema..." + npm run gen:event-schema + npm run openapi + git add openapi.json src/contract/eventSchema.generated.json +fi + +# Re-stage the previously-staged files (now possibly reformatted by Biome). +for file in $STAGED_FILES; do + [ -f "$file" ] && git add -- "$file" +done diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..16e8e66 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8b008e8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "window.title": "Agent-Server", + + // Biome formats with real tab characters; these control how wide a tab + // renders so the editor matches biome.json's indentWidth (3). + "editor.tabSize": 4, + "editor.detectIndentation": false, + + // Use Biome (see biome.json) as the single formatter on save so the editor + // matches the pre-commit hook (`biome check --write`) and avoids churn. + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, + "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, + "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, + "[javascriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, + "[json]": { "editor.defaultFormatter": "biomejs.biome" }, + "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" }, + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit", + "quickfix.biome": "explicit" + } +} diff --git a/README.md b/README.md index daa55d0..b4c8513 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,292 @@ # @appx/agent-server -Pi-SDK-based agent orchestration. Standalone HTTP/SSE service, one process per Appx app. +Pi-SDK-based agent orchestration. A standalone HTTP/SSE service that wraps the +[pi coding agent SDK](https://github.com/earendil-works/pi) into a stable +REST + SSE contract. -This is the **Agent Server Source** from the Appx App Anatomy: a self-contained -TypeScript service that wraps the [pi coding agent SDK](https://github.com/earendil-works/pi) -into a stable REST + SSE contract. Each Appx app launches its own -agent-server (single-tenant per process) and talks to it over loopback. +One process serves one organisation and orchestrates many **projects** — each an +isolated agent workspace (its own directory, config, and chat sessions) sharing +one set of LLM credentials. Projects are explicit, persisted resources: create +them via `POST /v1/projects`, then drive sessions under +`/v1/projects/{id}/sessions/*`. See +[`docs/architecture/project-lifecycle-and-workspace-layout.md`](docs/architecture/project-lifecycle-and-workspace-layout.md). ## Run it ```bash npm install npm run build -PROJECT_DIR=/abs/path/to/your/app npm start +WORKSPACE_DIR=/abs/path/to/workspace npm start # → listening on http://127.0.0.1:4001 # → docs at http://127.0.0.1:4001/docs # → spec at http://127.0.0.1:4001/openapi.json ``` -For dev with watch: - -```bash -PROJECT_DIR=/abs/path/to/your/app npm run dev -``` +Dev with watch: `WORKSPACE_DIR=/abs/path/to/workspace npm run dev`. ## Configuration All via env vars (see `.env.example`): -| Var | Required | Default | Notes | -| -------------------- | -------- | ---------------------------- | --------------------------------------------------------------------- | -| `PROJECT_DIR` | yes | — | cwd handed to pi; `.pi/skills/` discovery is rooted here | -| `SESSIONS_DIR` | no | `$PROJECT_DIR/data/sessions` | where pi writes session JSONL files | -| `AGENTS_FILE` | no | `.pi/AGENTS.md` | system prompt file (relative to `PROJECT_DIR` or absolute) | -| `ANTHROPIC_API_KEY` | no | — | injected into pi's AuthStorage; falls back to `~/.pi/agent/auth.json` | -| `AGENT_SERVER_HOST` | no | `127.0.0.1` | bind host | -| `AGENT_SERVER_PORT` | no | `4001` | bind port | -| `AGENT_SERVER_TOKEN` | no | — | if set, `/v1/*` requires `Authorization: Bearer ` | +| Var | Required | Default | Notes | +| -------------------- | -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `WORKSPACE_DIR` | yes | — | Root holding every project dir plus `.pi-global/`. Must exist. Mount as a Docker volume for restart-safe projects + registry. | +| `ANTHROPIC_API_KEY` | no | — | Injected into Pi's `AuthStorage` at boot; otherwise relies on `.pi-global/auth.json`. | +| `PI_EXTENSION_PATHS` | no | — | Comma-separated Pi extension/package sources (`npm:`, `git:`, or paths). | +| `PI_SKILL_PATHS` | no | — | Comma-separated Pi skill file/directory paths. | +| `PI_PROMPT_PATHS` | no | — | Comma-separated Pi prompt template paths. | +| `PI_THEME_PATHS` | no | — | Comma-separated Pi theme paths. | +| `PI_NO_EXTENSIONS` | no | `false` | `"true"` disables extension discovery except `PI_EXTENSION_PATHS`. | +| `PI_NO_SKILLS` | no | `false` | `"true"` disables skill discovery. | +| `PI_NO_PROMPTS` | no | `false` | `"true"` disables prompt template discovery. | +| `PI_NO_THEMES` | no | `false` | `"true"` disables theme discovery. | +| `LITELLM_BASE_URL` | no | — | When set, registers a `litellm` provider from `LITELLM_*` envs (see below). | +| `AGENT_SERVER_HOST` | no | `127.0.0.1` | Bind host. | +| `AGENT_SERVER_PORT` | no | `4001` | Bind port. | +| `AGENT_SERVER_TOKEN` | no | — | If set, `/v1/*` requires `Authorization: Bearer `. | + +Auth is opt-in: loopback-only single-user dev can leave `AGENT_SERVER_TOKEN` +unset. Set it for shared hosts or any deployment where another local process +could reach the port. + +## Filesystem layout + +Everything lives under `WORKSPACE_DIR`, so a single mounted volume makes projects +and the registry restart-safe: -Auth is opt-in. Loopback-only + single-user dev → unset is fine. Set -`AGENT_SERVER_TOKEN` for shared hosts or any deployment where another local -process could reach the port. +``` +WORKSPACE_DIR/ +├── .pi-global/ # org-global + agent-server state +│ ├── auth.json # Pi auth (keys are injected from env at boot, in-memory-first) +│ ├── models.json # Pi custom providers +│ ├── projects.json # durable project registry — source of truth +│ └── sessions/{id}/ # session transcripts, namespaced by project id +└── {id}/ # project working dir = app source + config + └── .pi/ # AGENTS.md, skills/, extensions/, settings.json (committable) +``` + +- `{id}` is the project slug (`id = slugify(name)`), immutable and used as the + registry key, route param, and directory name. +- Project `.pi/` holds **config only** and is committable. Session **transcripts** + are centralised under `.pi-global/sessions/{id}/`, so they never leak into a + project's git history and survive independently on the volume. +- A project with no `.pi/AGENTS.md` starts with no pinned prompt (silent skip); + Pi's normal context-file discovery then applies. +- LLM credentials are injected from env into memory at startup and are **not** the + job of the volume to persist (`auth.json` holds only non-secret/OAuth state). ## API -REST routes are defined with [Zod](https://zod.dev) via `@hono/zod-openapi`. -The OpenAPI 3.1 doc is the contract surface for consumers; types are -generated from it (see "Consuming from another app" below). +REST routes are defined with [Zod](https://zod.dev) via `@hono/zod-openapi`; the +OpenAPI 3.1 doc (`/openapi.json`) is the contract surface, and consumer types are +generated from it (see "Consuming from another app"). + +**Org-global** (`/v1`): + +| Method | Path | Description | +| -------- | -------------------------------------------------- | --------------------------------------------------- | +| `GET` | `/v1/sessions/models` | List selectable models and auth availability | +| `GET` | `/v1/auth/providers` | List provider auth status without secrets | +| `PUT` | `/v1/auth/providers/{provider}/api-key` | Store a provider API key | +| `DELETE` | `/v1/auth/providers/{provider}` | Remove a stored provider credential | +| `POST` | `/v1/auth/providers/{provider}/subscription/start` | Start a subscription OAuth flow | +| `GET` | `/v1/auth/subscription/{flowId}` | Read subscription flow state | +| `POST` | `/v1/auth/subscription/{flowId}/continue` | Continue a prompt/code step | +| `DELETE` | `/v1/auth/subscription/{flowId}` | Cancel a pending flow | +| `GET` | `/v1/custom/providers` | List custom `models.json` providers without secrets | +| `PUT` | `/v1/custom/providers` | Create or update a custom provider | +| `DELETE` | `/v1/custom/providers/{provider}` | Remove a custom provider | +| `GET` | `/v1/healthz` | Liveness + per-channel SSE subscriber counts | + +**Project lifecycle** (`/v1/projects`): + +| Method | Path | Description | +| -------- | ------------------- | --------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects` | `{ name }` — create-or-get a project (idempotent on name). Returns `{ id, name, projectDir, createdAt }`. | +| `GET` | `/v1/projects` | List registered projects | +| `GET` | `/v1/projects/{id}` | Get one project's metadata | +| `DELETE` | `/v1/projects/{id}` | Remove the runtime, metadata, working dir, and transcripts | + +**Sessions** (under `/v1/projects/{projectId}`): + +| Method | Path | Description | +| ------- | --------------------------------------------------- | --------------------------------------- | +| `GET` | `…/sessions` | List sessions (persisted + live) | +| `POST` | `…/sessions` | Create a new session | +| `GET` | `…/sessions/{id}` | Persisted message history | +| `GET` | `…/sessions/{id}/settings` | Active model/thinking settings | +| `PATCH` | `…/sessions/{id}/settings` | Switch model and/or thinking while idle | +| `GET` | `…/sessions/{id}/events` | SSE stream of pi `AgentSessionEvent`s | +| `GET` | `…/sessions/{id}/extension-ui` | Pending extension UI requests | +| `POST` | `…/sessions/{id}/extension-ui/{requestId}/response` | Resolve an extension UI request | +| `POST` | `…/sessions/{id}/prompt` | `{ text }` — send a user prompt | +| `POST` | `…/sessions/{id}/abort` | Abort the in-flight run (no-op if idle) | + +Session routes resolve their runtime by a pure lookup on the path `id`; a request +for a project that was never created returns `404`. + +Plus `GET /openapi.json` (OpenAPI 3.1) and `GET /docs` (Swagger UI). + +### SSE wire format + +Each SSE event is `data: ` carrying a `WireEvent` — pi's `AgentSessionEvent` +plus the `extension_ui_request` / `extension_error` events agent-server injects. +The schema is **generated from pi's TypeScript types** (via typia, +`scripts/genEventSchema.ts`) and merged into `openapi.json` as `WireEvent`, so +clients codegen the event + message types (`ToolCall`, `AssistantMessage`, …) +from the same contract as the REST surface — no hand-mirroring, no importing pi +in clients. Regenerate after a pi upgrade with `npm run gen:event-schema`; the +resulting `eventSchema.generated.json` is committed. + +Non-JSON lines also occur and should be ignored: an initial `connected to ` +line and periodic `heartbeat` keepalives (every 15s). Outgoing events are +classified server-side against the contract (forward-compatible: an unmodeled +`type` is forwarded with a soft log; the stream is never broken). + +Handle `message_update.assistantMessageEvent` by `contentIndex`: text blocks use +`text_start`/`text_delta`/`text_end`, tool-call blocks use +`toolcall_start`/`toolcall_delta`/`toolcall_end`, and thinking blocks may be +emitted without being shown in the transcript. + +Extension UI requests arrive on the same stream as +`{ "type": "extension_ui_request", ... }`. Blocking requests (`select`, `confirm`, +`input`, `editor`) are held until the browser answers +`POST …/sessions/{id}/extension-ui/{requestId}/response` with one of +`{ "value": "…" }`, `{ "confirmed": true }`, or `{ "cancelled": true }`. After +connecting/reconnecting, call `GET …/sessions/{id}/extension-ui` so requests +created before the SSE connection aren't missed. + +## Models and thinking + +`GET /v1/sessions/models` returns non-secret Pi model metadata (provider, id, +display name, API family, reasoning support, auth availability, context window, +max output tokens, default thinking level). + +`PATCH …/sessions/{id}/settings` accepts: + +```json +{ + "provider": "anthropic", + "modelId": "claude-sonnet-4-5", + "thinkingLevel": "high" +} +``` -Mounted under `/v1`: +`thinkingLevel` is one of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. +Changes during streaming return `409`; Pi clamps unsupported levels to the +model's supported set and returns the effective level. -| Method | Path | Description | -| ------ | -------------------------- | ----------------------------------------------------- | -| `GET` | `/v1/sessions` | List sessions (persisted + in-memory not yet flushed) | -| `POST` | `/v1/sessions` | Create a new session | -| `GET` | `/v1/sessions/{id}` | Persisted message history | -| `GET` | `/v1/sessions/{id}/events` | SSE stream of pi `AgentSessionEvent`s | -| `POST` | `/v1/sessions/{id}/prompt` | `{ text }` — send a user prompt | -| `POST` | `/v1/sessions/{id}/abort` | Abort the in-flight run (no-op if idle) | -| `GET` | `/v1/healthz` | Liveness + per-channel SSE subscriber counts | +### LiteLLM -Plus: +When `LITELLM_BASE_URL` is set, the server registers a Pi provider named +`litellm`. Useful envs: `LITELLM_API_KEY`, `LITELLM_DEFAULT_MODEL`, +`LITELLM_MODELS` (comma-separated ids), `LITELLM_MODELS_JSON` (full per-model +config: `reasoning`, `thinkingLevelMap`, `defaultThinkingLevel`, `compat`, `api`, +token limits), `LITELLM_DEFAULT_THINKING`, and `LITELLM_API` +(`openai-completions` | `openai-responses` | `anthropic-messages`). Presets exist +for `openai/gpt-5.5`, `deepseek/deepseek-v4-pro`, and `deepseek/deepseek-v4-flash`. -- `GET /openapi.json` — OpenAPI 3.1 document -- `GET /docs` — Swagger UI +The same shape can be managed at runtime via `PUT /v1/custom/providers`; records +are written to `.pi-global/models.json` with `0600` perms and reloaded +immediately. Responses only report whether a key exists, never the key. -### SSE wire format +### Provider auth -Each SSE event is `data: ` carrying a pi `AgentSessionEvent`. The -agent-server intentionally does not lock down a Zod schema for the union — -pi owns that contract, and consumers (the eventx frontend reducer) -interpret it directly. A `heartbeat` named event is sent every 15s; clients -using `EventSource` with a default `onmessage` handler ignore it. +`GET /v1/auth/providers` merges Pi model availability, stored API keys, +runtime/env credentials, `models.json` keys, and registered OAuth providers into +one non-secret status list. Use `PUT /v1/auth/providers/{provider}/api-key` for +API keys, or `POST /v1/auth/providers/{provider}/subscription/start` for +subscription auth (some providers, e.g. OpenAI Codex / Anthropic, require pasting +the browser's final localhost redirect back through +`POST /v1/auth/subscription/{flowId}/continue`). -## Consuming from another app +## Extensions + +Pi packages and extensions execute code in the agent process. Keep configuration +conservative, review package source before enabling, and prefer project-local +`.pi/settings.json` or `PI_EXTENSION_PATHS` over global installs. For first-party +app bundles, put prompt/skill/extension assets under the project's `.pi/` and let +Pi discover them; the `PI_*_PATHS` vars are for temporary overlays or package +sources that shouldn't be committed to the workspace. -Generate the static `openapi.json` once after a build, then feed it to -`openapi-typescript` (or any other generator) in the consuming app: +## Regenerating `openapi.json` + +`openapi.json` is the published contract — REST routes (described by +`@hono/zod-openapi`) **and** the SSE `WireEvent` schema, which is generated from +pi's TypeScript types via typia rather than hand-authored. ```bash -# in this repo +# only needed after a pi upgrade or a change to WireEvent — regenerates +# src/contract/eventSchema.generated.json (the committed event schema). +npm run gen:event-schema + +# always: rebuild and dump the merged contract to ./openapi.json npm run build -npm run openapi # writes ./openapi.json +npm run openapi +``` + +`gen:event-schema` requires the typia compiler transform ( ts-patch / `tspc`, +already wired via `tsconfig.gen.json`); the resulting JSON is committed so the +normal `build`/`openapi`/runtime never need it. The live server serves the same +merged document at `/openapi.json`. +## Consuming from another app + +Feed the generated `openapi.json` to `openapi-typescript` (or any generator) to +get typed REST DTOs **and** the SSE event/message types (`WireEvent`, `ToolCall`, +`AssistantMessage`, …) — so consumers never re-derive pi's shapes or import pi: + +```bash # in the consuming app npx openapi-typescript ../../agent-server/openapi.json -o src/generated/agent-server.d.ts ``` -Then use `openapi-fetch` (or any client of your choice) with the generated -types. Example (eventx-backend): +Then use a typed client; SSE is consumed separately (native `EventSource`, or +piped through the consumer backend with `fetch().body` streaming): ```ts import createClient from "openapi-fetch"; import type { paths } from "./generated/agent-server.js"; const client = createClient({ baseUrl: "http://127.0.0.1:4001" }); -const { data, error } = await client.GET("/v1/sessions"); +const { data } = await client.POST("/v1/projects", { + body: { name: "my-app" }, +}); ``` -SSE is consumed separately (native `EventSource` in the browser, or piped -through the consumer backend with `fetch().body` streaming). - ## Library mode (advanced) -If you'd rather embed the runtime inside your own Hono app: +To embed the runtime in your own Hono app. `ProjectRegistry.create` is async (it +sets up shared auth/model state and rehydrates the project registry from +`projects.json`); runtimes are built lazily on first use. ```ts import { Hono } from "hono"; -import { AgentRuntime, createSessionsApp } from "@appx/agent-server"; - -const runtime = new AgentRuntime({ projectDir, sessionsDir, agentsFile }); +import { + ProjectRegistry, + createCredentialsApp, + createProjectsApp, + createSessionsApp, +} from "@appx/agent-server"; + +const registry = await ProjectRegistry.create({ workspaceDir }); const app = new Hono(); -app.route("/v1", createSessionsApp(runtime)); -``` - -This exists for tests and for hosts that have a strong reason to share a -process. The standalone server is the primary deployment. - -## Pi specifics -See `apps/eventx/CLAUDE.md` "Pi specifics" section for the gotchas. Headlines: - -- Pi writes session JSONL files lazily (on first `message_end`), so listing - merges disk + live in-memory sessions. -- `text_delta` events carry chunks in `delta`; `partial` is the full message - object, not a string. -- Tool result messages have `role: "toolResult"` and arrive after the - tool-using assistant's `message_end`. - -## Why Hono? - -Schema-first OpenAPI (Zod is the single source of truth for validation, -types, and the published spec) and first-class SSE (`streamSSE` handles -abort propagation and keepalives properly). Plus one piece of forward- -looking leverage: - -**Runtime portability.** Hono speaks Web Standards (`Request` / -`Response` / `ReadableStream`) and runs on Node, Bun, Deno, Workers, and -edge. Today we run on Node only via `@hono/node-server`. The realistic -future is **Bun**, because pi has first-class Bun support (`bun-binary` -install mode, `bun build --compile` recipe in pi's own `package.json`, -runtime detection via `isBunBinary` / `isBunRuntime`, WASM-path patching -for compiled binaries). That unlocks shipping pi + agent-server + an -app's skills as a single static executable per app, no Node on the host. - -To migrate when we want it, replace the `serve()` call in -`src/server.ts` with a runtime-detect: - -```ts -if (typeof globalThis.Bun !== "undefined") { - globalThis.Bun.serve({ fetch: root.fetch, hostname: host, port }); -} else { - const { serve } = await import("@hono/node-server"); - serve({ fetch: root.fetch, hostname: host, port }); -} +app.route("/v1", createCredentialsApp(registry.credentials)); // org-global auth/custom/models +app.route("/v1", createProjectsApp(registry)); // project lifecycle +app.route( + "/v1/projects/:projectId", + createSessionsApp(async (c) => { + const runtime = await registry.getRuntime(c.req.param("projectId")); + if (!runtime) throw new Error("project not registered"); // map to 404 in onError + return runtime; + }), +); ``` -Plus a `dev:bun` script. Routes, schemas, runtime, and the broker are -already runtime-agnostic. Workers / Deno / edge are out regardless: pi -needs a filesystem to persist session JSONL. +Projects are created with `registry.createProject({ name })`; each runtime derives +its working dir (`WORKSPACE_DIR/{id}`), centralised sessions +(`.pi-global/sessions/{id}`), AGENTS.md, skills, and extensions automatically. The +registry holds only org-shared state (auth, models, credentials, project registry). +The standalone server (`src/server.ts`) is the primary deployment; this exists for +tests and embedded hosts. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..0a091e5 --- /dev/null +++ b/biome.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off", + "useConst": "error", + "useNodejsImportProtocol": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noControlCharactersInRegex": "off", + "noEmptyInterface": "off" + } + } + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "tab", + "indentWidth": 3, + "lineWidth": 120 + }, + "files": { + "includes": [ + "src/**/*.ts", + "test/**/*.ts", + "scripts/**/*.ts", + "!**/node_modules/**/*", + "!dist/**/*", + "!**/*.generated.*" + ] + } +} diff --git a/docs/PRs/arch_pi_model_thinking_extensions.md b/docs/PRs/arch_pi_model_thinking_extensions.md new file mode 100644 index 0000000..82558fd --- /dev/null +++ b/docs/PRs/arch_pi_model_thinking_extensions.md @@ -0,0 +1,466 @@ +# Arch Doc — Pi Model, Thinking, Extensions, and Multi-Project Routing + +> Branch: `codex/pi-model-thinking-extensions` vs `main` +> Scope: 12 commits, ~6,000 lines added across 14 files +> Generated: 2026-05-25 + +--- + +## Table of Contents + +- [Arch Doc — Pi Model, Thinking, Extensions, and Multi-Project Routing](#arch-doc--pi-model-thinking-extensions-and-multi-project-routing) + - [Table of Contents](#table-of-contents) + - [1. Overview (plain-English)](#1-overview-plain-english) + - [What was deliberately _not_ done](#what-was-deliberately-not-done) + - [2. System Map](#2-system-map) + - [2.1 High-level data flow](#21-high-level-data-flow) + - [2.2 Mode comparison](#22-mode-comparison) + - [2.3 New \& changed API endpoints](#23-new--changed-api-endpoints) + - [2.4 OAuth subscription flow state machine](#24-oauth-subscription-flow-state-machine) + - [3. Code Review Guide](#3-code-review-guide) + - [3.1 `src/runtime.ts` — the heart of the change](#31-srcruntimets--the-heart-of-the-change) + - [3.2 `src/runtimeRegistry.ts` (new)](#32-srcruntimeregistryts-new) + - [3.3 `src/litellm.ts` (new)](#33-srclitellmts-new) + - [3.4 `src/schemas.ts`](#34-srcschemasts) + - [3.5 `src/routes.ts`](#35-srcroutests) + - [3.6 `src/server.ts`](#36-srcserverts) + - [3.7 `src/openapi.ts`, `src/index.ts`](#37-srcopenapits-srcindexts) + - [4. Testing Guide](#4-testing-guide) + - [4.1 Automated coverage (`test/server.test.ts`)](#41-automated-coverage-testservertestts) + - [4.2 Manual verification checklist](#42-manual-verification-checklist) + - [5. Architecture \& Code Pitfalls](#5-architecture--code-pitfalls) + - [6. Fixed Pitfalls](#6-fixed-pitfalls) + - [7. TODOs \& Future Improvements](#7-todos--future-improvements) + +--- + +## 1. Overview (plain-English) + +This PR turns `agent-server` from a **single-tenant Pi wrapper** into a **multi-tenant agent runtime hub** that powers both standalone apps (Eventx-style, one process per project) _and_ a shared Appx host (one process serving many project workspaces). + +The work decomposes into five conceptually distinct features that landed in sequence: + +1. **Model + thinking-level controls.** Sessions can now switch between models and adjust the Pi "thinking level" (`off → xhigh`) at runtime. The runtime exposes which models are available, which thinking levels each model supports, and clamps unsupported requests to the nearest valid level. A new `PATCH /v1/sessions/{id}/settings` endpoint drives this from the frontend, rejected with `409` while the agent is streaming. + +2. **Pi extension bridge.** Pi extensions can request UI interactions (`select`, `confirm`, `input`, `editor`, `notify`, `setStatus`, `setWidget`, `setTitle`, …). The runtime forwards these as `extension_ui_request` events on the session SSE stream, and the browser answers via `POST /v1/sessions/{id}/extension-ui/{requestId}/response`. Resource overlays (`PI_EXTENSION_PATHS`, `PI_SKILL_PATHS`, `PI_PROMPT_PATHS`, `PI_THEME_PATHS`) and discovery toggles (`PI_NO_*`) let app hosts pin or sandbox what Pi loads at startup. + +3. **Provider auth, OAuth flows, and custom providers.** What used to be "drop your `ANTHROPIC_API_KEY` in env" is now a full credential surface: list providers, store/delete API keys, run an OAuth subscription login (Anthropic, OpenAI Codex, etc.), and define custom OpenAI-compatible providers in `models.json`. The OAuth flow is a small in-memory state machine with progress, prompt, and manual-redirect-paste callbacks; subscription tokens never leave the server. + +4. **LiteLLM integration.** Setting `LITELLM_BASE_URL` (+ `LITELLM_*` env vars) registers a `litellm` Pi provider with hardcoded presets for `openai/gpt-5.5`, `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`. Per-model thinking maps, default thinking levels, and OpenAI-compatible quirks (`thinkingFormat`, `supportsReasoningEffort`, `maxTokensField`) are all configurable via env JSON. + +5. **Multi-project mode.** `AGENT_SERVER_MODE=multi` adds a route split: shared auth/model state stays at `/v1/{auth,custom,...}`, per-project sessions live under `/v1/projects/{projectId}/...`, and the project's working directory is supplied by Appx via the `X-Appx-Project-Dir` header (trusted because Appx validates `projectId` first). An `AgentRuntimeRegistry` lazily creates one `AgentRuntime` per project, all sharing one `AuthStorage` + `ModelRegistry`, but each with its own `sessionsDir` (`/data/sessions`) and `.pi/AGENTS.md` system prompt. + +The unifying design choice: **one shared credentials/model surface, many project-scoped session runtimes**. This is what makes Appx workable — users authenticate Anthropic once, but their sessions remain isolated per project workspace. + +### What was deliberately _not_ done + +- Pi's `AgentSessionEvent` union is **not** locked into a Zod schema. Pi owns that contract; duplicating it here would drift. The SSE endpoint is documented as opaque `text/event-stream`. +- The OAuth flow does not persist state across restarts. Flows are short-lived (10 min expiry) and live entirely in memory; this matches Pi's own login UX where pressing F5 cancels. +- The custom-provider `models.json` writer doesn't merge — it replaces a provider's entire entry on `PUT`. Calling code must re-send all models. + +--- + +## 2. System Map + +### 2.1 High-level data flow + +``` + ┌────────────────────────┐ + │ Pi SDK │ + │ - AuthStorage │ + │ - ModelRegistry │ + │ - AgentSession │ + │ - SessionManager │ + │ - ResourceLoader │ + └───────────┬────────────┘ + │ + ▼ +┌─────────────┐ HTTP ┌─────────────┐ ┌─────────────────────────────┐ +│ Frontend / │────────▶│ server.ts │───▶│ AgentRuntimeRegistry [NEW] │ +│ Appx host │ SSE │ (entrypoint)│ │ - shared AuthStorage │ +└─────────────┘◀────────│ [UPDATED] │ │ - shared ModelRegistry │ + └──────┬──────┘ │ - default + per-project │ + │ │ AgentRuntime instances │ + │ └──────────────┬──────────────┘ + │ │ + ▼ ▼ + ┌─────────────┐ ┌──────────────────────┐ + │ routes.ts │─────────▶│ AgentRuntime │ + │ [UPDATED] │ │ [HEAVILY UPDATED] │ + │ - sessions │ │ - sessions/models │ + │ - auth │ │ - extension UI │ + │ - custom │ │ - OAuth flows │ + │ - extension │ │ - custom providers │ + │ UI │ │ - thinking clamp │ + └──────┬──────┘ └──────────┬───────────┘ + │ │ events + ▼ ▼ + ┌──────────────┐ ┌───────────────┐ + │ sseBroker.ts │◀───────────│ publish() │ + │ (unchanged) │ └───────────────┘ + └──────────────┘ +``` + +### 2.2 Mode comparison + +``` +SINGLE MODE (AGENT_SERVER_MODE=single, default) +───────────────────────────────────────────────────────── + /v1/sessions → defaultRuntime + /v1/sessions/{id}/... → defaultRuntime + /v1/auth/... → defaultRuntime + /v1/custom/... → defaultRuntime + /v1/healthz → defaultRuntime + +MULTI MODE (AGENT_SERVER_MODE=multi) +───────────────────────────────────────────────────────── + /v1/auth/... → defaultRuntime (shared creds) + /v1/custom/... → defaultRuntime (shared models.json) + /v1/healthz → defaultRuntime + /v1/projects/{id}/sessions... + → registry.forProject({ + id, projectDir: header + }) + → per-project AgentRuntime + (own sessionsDir, + own AGENTS.md, + shared AuthStorage, + shared ModelRegistry) +``` + +### 2.3 New & changed API endpoints + +All endpoints are mounted under `/v1` (or `/v1/projects/{projectId}` in multi mode for session-scoped routes). + +| Method | Path | Tag | Purpose | +| ---------- | ------------------------------------------------------ | ---------- | ----------------------------------------------------------------- | +| GET | `/sessions` | sessions | List sessions (existed; extended to merge in-memory + on-disk) | +| POST | `/sessions` | sessions | Create new session (existed) | +| GET | `/sessions/{id}` | sessions | Persisted history (existed) | +| GET | `/sessions/{id}/events` | sessions | SSE stream (existed; now also delivers extension UI requests) | +| POST | `/sessions/{id}/prompt` | sessions | Send user prompt (existed; new `steer` semantics while streaming) | +| POST | `/sessions/{id}/abort` | sessions | Abort run (existed) | +| **GET** | **`/sessions/models`** | models | List all models with availability + thinking metadata | +| **GET** | **`/sessions/{id}/settings`** | models | Active model + thinking level | +| **PATCH** | **`/sessions/{id}/settings`** | models | Switch model and/or thinking level (409 if streaming) | +| **GET** | **`/sessions/{id}/extension-ui`** | extensions | Pending extension UI requests (catch-up after reconnect) | +| **POST** | **`/sessions/{id}/extension-ui/{requestId}/response`** | extensions | Resolve extension UI request | +| **GET** | **`/auth/providers`** | auth | Non-secret provider auth status | +| **PUT** | **`/auth/providers/{provider}/api-key`** | auth | Store API key in Pi auth storage | +| **DELETE** | **`/auth/providers/{provider}`** | auth | Remove stored credential | +| **POST** | **`/auth/providers/{provider}/subscription/start`** | auth | Begin OAuth login flow | +| **GET** | **`/auth/subscription/{flowId}`** | auth | Read OAuth flow state | +| **POST** | **`/auth/subscription/{flowId}/continue`** | auth | Submit prompt input or pasted redirect URL | +| **DELETE** | **`/auth/subscription/{flowId}`** | auth | Cancel OAuth flow | +| **GET** | **`/custom/providers`** | models | List `models.json` custom providers | +| **PUT** | **`/custom/providers`** | models | Create/update custom provider | +| **DELETE** | **`/custom/providers/{provider}`** | models | Remove custom provider | +| GET | `/healthz` | meta | Liveness + per-channel SSE counts (existed) | + +**Bold** = new in this PR. + +### 2.4 OAuth subscription flow state machine + +``` + ┌──────────┐ + │ starting │ flow created, awaiting Pi callback + └─────┬────┘ + │ Pi calls onAuth(url, instructions) + ▼ + ┌──────┐ onPrompt(prompt) + │ auth │──────────────────────────────────────────┐ + └──┬───┘ ▼ + │ ┌────────────┐ + │ user pastes manual redirect URL │ prompt │ + │ (or Pi's local callback returns) │ (input req)│ + │ └─────┬──────┘ + ▼ │ + ┌─────────┐ │ + │ waiting │◀─────────────────────────────────────┘ + └────┬────┘ POST /continue resolves + │ + │ Pi login() resolves → Pi writes auth.json + ▼ + ┌──────────┐ on error: ┌───────┐ on cancel: ┌───────────┐ + │ complete │ │ error │ │ cancelled │ + └──────────┘ └───────┘ └───────────┘ + │ │ │ + └────────────┬───────────┴────────────────────────┘ + ▼ + 60s cleanup timer (10min for inactive flows) + flow evicted from `pendingOAuthFlows` +``` + +`activeOAuthFlowForProvider()` short-circuits a re-entrant `start` if a non-terminal, non-expired flow already exists for that provider — fixes the "second start kills first" footgun (commit `edd6d6f`). + +--- + +## 3. Code Review Guide + +Walk top-to-bottom. Each file's section starts with **why it changed**, then key decisions, then specific things to verify. + +### 3.1 `src/runtime.ts` — the heart of the change + +**Size:** 305 → 1257 lines. This is where ~75% of the new logic lives. + +**Why it changed.** The runtime grew four new responsibilities: (a) model/thinking-level management with clamping; (b) extension UI bridge; (c) provider auth + OAuth flow management; (d) custom-provider `models.json` CRUD. + +**Key decisions:** + +- **Shared `AuthStorage` / `ModelRegistry`.** The constructor now accepts `authStorage` and `modelRegistry` from the registry instead of always allocating its own. This is what makes "one shared credential surface" work: every per-project runtime points at the same auth file, so `PUT /auth/providers/anthropic/api-key` once and every project sees it (`runtime.ts:320, 349`). + +- **Thinking-level clamping** (`runtime.ts:368–391`). Pi advertises which thinking levels a model supports via `thinkingLevelMap` (`null` means unsupported). When the user requests `xhigh` on a model that only supports up to `high`, we clamp upward first, then downward. The default for a non-reasoning model is always `["off"]`. **Verify:** the search order (`requestedIndex → end`, then `requestedIndex-1 → 0`, then `available[0]`) — does it ever return `undefined` if the model has zero supported levels? It guards with `?? "off"` at line 390, but reasoning models with `thinkingLevelMap: { off: null, ... }` could plausibly return `[]`. Worth a defensive test. + +- **`makeResourceLoader()` per session** (`runtime.ts:445–471`). Pi's SDK builds a default loader if you don't pass one. We always pass our own so we can suppress ancestor `AGENTS.md` discovery (`noContextFiles: this.systemPrompt !== undefined`). A new loader per session is fine — Pi creates one anyway. **Verify:** is `loader.reload()` cheap enough on every `createNewSession` / `ensureSession`? If extension/skill paths are large this could matter. + +- **Extension UI bridge** (`runtime.ts:473–621`). `createExtensionUiContext` returns the full `ExtensionUIContext` Pi expects. Blocking dialogs (`select`, `confirm`, `input`, `editor`) build a Promise, register in `pendingExtensionUi`, publish an `extension_ui_request` SSE event, and resolve when the browser POSTs back. Non-blocking effects (`notify`, `setStatus`, `setWidget`, `setTitle`, `pasteToEditor`, `setEditorText`) are fire-and-forget publishes. Theme/working-message/footer/header are stubbed because the agent-server has no UI of its own. + +- **`createDialogPromise` cleanup is defensive** (`runtime.ts:486–515`). Both `timeout` and `signal` cancel paths route through the same `finish` lambda, which checks `pendingExtensionUi.has(id)` before resolving — this prevents the same Promise from resolving twice if the timeout and the response race. + +- **OAuth flow state machine** (`runtime.ts:869–1062`). One `PendingOAuthFlow` entry per active flow. `version` increments on every state mutation; `waitForOAuthFlowUpdate` resolves the next time `version` advances or after 15s. This is what makes the GET-state polling pattern work without thundering retries. + +- **`activeOAuthFlowForProvider`** (`runtime.ts:900–909`). When the user re-clicks "Sign in with Anthropic" while a flow is already in flight, we return the existing flow instead of starting a new one. **Why:** Pi's `login()` opens a local HTTP listener on a fixed port — calling it twice gets `EADDRINUSE`. Without this, the second click would kill the first flow. + +- **`oauthLoginErrorMessage`** (`runtime.ts:911–917`). String-matches `EADDRINUSE` to produce a friendlier message. **Fragile by design** — Pi or Node could change the message format. There's a test for the current format (`server.test.ts:462`). + +- **`models.json` permissions** (`runtime.ts:1078–1081`). Writes are followed by `chmodSync(..., 0o600)`. Pi expects this for credential files; without it, Pi may refuse to load. **Verify on Windows:** `chmodSync` is a no-op on NTFS, but neither is the world-readable threat — fine in practice, worth knowing. + +- **Prompt steering** (`runtime.ts:1228–1242`). When the agent is mid-stream, `prompt()` is called with `streamingBehavior: "steer"`. This interrupts the current assistant turn at the next tool boundary instead of waiting for it to fully stop (`"followUp"`). Equivalent to `session.steer(text)`. The comment in the code is critical context for anyone reading this for the first time. + +**What to verify in this file:** + +- Concurrency: two simultaneous `setSessionModel()` calls on the same id — both check `isStreaming` first, but neither holds a lock. Could one set the model and the other get a stale `false` for `isStreaming` before sending a prompt? Mitigated because Pi's `setModel` is sync-ish and `isStreaming` flips inside Pi's prompt path, but worth thinking through. +- The `live` map (`runtime.ts:290`) has no eviction. Long-running multi-project hosts will accumulate sessions. The `// todo: rename to liveSessions` is a hint there's pending work here. +- `assertProviderId` (`runtime.ts:857–861`) regex `^[a-zA-Z0-9_.:-]+$` — note `:` is allowed for provider URIs like `npm:foo`. Anthropic-style ids only need `a-z0-9-`. The regex is the right level of permissive but reviewer should confirm there's no path injection risk via crafted provider names downstream. + +### 3.2 `src/runtimeRegistry.ts` (new) + +121 lines. The simplest "factory + cache" pattern for the multi-project mode. + +**What it does:** + +- Builds one `AuthStorage` + one `ModelRegistry` for the host (line 57–62). +- Eagerly creates a `defaultRuntime` against the configured `projectDir`. +- Lazily creates per-project runtimes on `forProject({ id, projectDir })`, keyed by `id`. + +**Key decisions:** + +- `defaultAgentsFile: false` lets multi-project hosts opt out of loading an `AGENTS.md` for the default runtime — useful when the host's `PROJECT_DIR` is just a placeholder root and only project-scoped runtimes have real prompts (`runtimeRegistry.ts:52, 87–91`). +- Project session dirs are forced under `/data/sessions` rather than the global `sessionsDir`. The default runtime keeps its configured `sessionsDir` (line 104–107). +- `projectExtensionPaths` defaults to `[".pi/extensions/appx-guardrails.ts"]` — a forward-looking convention so Appx can ship a permission-gating extension into every project without each app having to opt in (line 52, 92–95). Currently a no-op unless the file exists. + +**What to verify:** + +- Cache key is just `context.id` (line 76). If two requests claim the same `id` but different `projectDir`, the second creates a new runtime and replaces the cached one (line 77 `existing?.projectDir === projectDir`). Trust here flows from "Appx validates `projectId` first" — if that ever changes, this cache could be poisoned via header. Worth a sanity check in the Appx middleware. +- No eviction of unused project runtimes. Long-lived processes will hold one set of session maps per project ever touched. + +### 3.3 `src/litellm.ts` (new) + +495 lines. Translates `LITELLM_*` env vars into a Pi provider config. + +**What it does:** lazily resolves a `ResolvedLiteLlmConfig` from environment, registers a `litellm` provider with the `ModelRegistry`, and seeds the runtime's default model + thinking level. + +**Key decisions:** + +- **Module-level cache** (`cachedConfig`). Mutated in tests via `resetLiteLlmConfigForTests`. Idempotent at startup so `logLiteLlmStartupConfig()` and `litellmRuntimeConfig()` don't re-parse. +- **Hardcoded presets** for `openai/gpt-5.5` and DeepSeek V4 (lines 131–161). These bake in non-trivial provider quirks (thinkingFormat, max_tokens field name, OpenAI Responses API vs Completions). Reasonable for this stage but coupling — a new model means editing this file. +- **Compat layering** (`modelCompat`, line 199): provider compat → preset compat → model compat. Each layer overrides earlier. This is how `LITELLM_COMPAT_JSON` (provider-wide) interacts with `LITELLM_MODELS_JSON` (per-model `compat` field). +- **`litellmRequestHint`** (lines 339–372) is a debug aid that prints the actual thinking field that will be sent on the wire (`reasoning.effort=high`, `enable_thinking=true`, etc.) for each thinkingFormat. Logged at startup so on-call can see whether the env produces the expected request shape. + +**What to verify:** + +- Throws thrown from `parseModels` (line 311–313 in `LITELLM_MODELS_JSON` parsing) crash startup. This is intentional — bad config should fail loudly — but the surrounding `logLiteLlmStartupConfig()` wraps a single `resolveLiteLlmConfig()` so the throw bubbles. Confirmed: `server.ts:113` is called _before_ the registry, so a bad config exits cleanly. +- `clampThinkingLevel` is duplicated here (lines 250–263) and in `runtime.ts` (`clampThinkingLevelForModel`). Logic is identical. Acceptable duplication for module decoupling, but a refactor opportunity. + +### 3.4 `src/schemas.ts` + +92 → 267 lines. New Zod schemas for every new endpoint listed in §2.3. No surprises — they mirror the runtime types in `runtime.ts`. + +The one thing worth noting: `ExtensionUiResponseRequestSchema` is a Zod **union** of three exclusive shapes: + +```ts +z.union([ + z.object({ value: z.string() }), + z.object({ confirmed: z.boolean() }), + z.object({ cancelled: z.literal(true) }), +]); +``` + +This means `{ cancelled: false }` is rejected — the response schema only accepts `cancelled: true`. The runtime's `ExtensionUiResponse` type is wider than the wire schema; the schema is intentionally narrow. **Verify:** the frontend doesn't ever send `{ cancelled: false }` thinking that means "not cancelled". + +### 3.5 `src/routes.ts` + +298 → 866 lines. Mostly mechanical: each new schema gets a `createRoute` definition + a thin handler that calls into the runtime. + +**Key decisions:** + +- `AgentRuntimeResolver` (`routes.ts:76`). The runtime can be passed as a function `(c: Context) => AgentRuntime | Promise` instead of an instance — this is what powers project-scoped routes that derive the runtime from request headers (`server.ts:133`). +- `CreateSessionsAppOptions` (`routes.ts:77–87`). Three booleans toggle whole route groups: `credentialRoutes`, `sessionRoutes`, `healthRoute`. In multi mode, `/v1` mounts with `sessionRoutes: false` and `/v1/projects/:projectId` mounts with `credentialRoutes: false, healthRoute: false`. This lets the same factory build both ends of the split. +- `settingsErrorStatus` (`routes.ts:95–101`) maps runtime errors to HTTP codes by **string-matching** `"not found"` / `"running"` / `"No API key"`. Fragile, see §5. +- The PATCH settings handler (`routes.ts:564–571`) does its own input validation — `provider` and `modelId` must come together, and at least one of `provider`/`thinkingLevel` must be present. The Zod schema doesn't express this XOR, so it lives in the handler. +- The SSE endpoint stays a plain Hono handler (lines 802–862) with `openAPIRegistry.registerPath` for documentation only. The streaming queue/wakeup pattern is unchanged from before — events queue while the writer is parked, the writer drains on each wakeup. +- After connecting, the SSE handler immediately replays `pendingExtensionUiRequests(id)` (line 834). This matters because the agent may have raised an extension dialog before the browser reconnected; without replay the user would see no prompt. + +**What to verify:** + +- Prompt is fire-and-forget (`routes.ts:708`). Errors only log to console — they don't reach the SSE stream. If the agent throws synchronously inside `sendPrompt` after passing `ensureExtensionsReady`, the user sees nothing. Pi events should cover the streaming-error path, but a synchronous throw before stream start could be silent. +- `abort` returns 404 on any error (`routes.ts:741`). If the runtime throws "session not found" the 404 is right; any other error also gets 404. Probably wrong but low-impact. + +### 3.6 `src/server.ts` + +122 → 237 lines. New responsibilities: parse `AGENT_SERVER_MODE`, parse all the new `PI_*` env lists, choose between single-mount and split-mount. + +**Key decisions:** + +- `parseMode` accepts aliases (`single`/`standalone`, `multi`/`multi-project`/`appx`). Defensive but not strictly necessary — could simplify. +- `projectRuntimeFromRequest` reads `X-Appx-Project-Dir` and `X-Appx-Project-Name` headers. These are **trusted** because the comment at line 13 explicitly says Appx validates `projectId` first. The error handler (line 167–173) maps any thrown "project context required" to a 400. +- LiteLLM startup is logged before registry construction (line 113) so the operator can see model/thinking config independently of whether the registry succeeds. +- The root handler (line 210–223) advertises which session path applies (`/v1/sessions` vs `/v1/projects/:projectId/sessions`) — useful for consumers that hit `/` to discover. + +**What to verify:** + +- If `AGENT_SERVER_TOKEN` is set, the bearer middleware applies to **all** `/v1/*` including the project-scoped routes. Confirmed at line 153–162. Good. +- The hard-coded magic header `x-appx-project-dir` is referenced in three places (`server.ts:135`, `runtimeRegistry.ts` indirectly, `test/server.test.ts`). Worth a `const APPX_PROJECT_DIR_HEADER` if it grows further. + +### 3.7 `src/openapi.ts`, `src/index.ts` + +Both small, mechanical updates: + +- `openapi.ts` now respects `AGENT_SERVER_MODE` so `npm run openapi` can emit either spec variant (lines 18–44). This is what consumers like eventx-backend run at build time. +- `index.ts` re-exports `AgentRuntimeRegistry`, `litellm` helpers, and the new types so library-mode embedders (the Hono-style example in the README) can wire it up. + +--- + +## 4. Testing Guide + +### 4.1 Automated coverage (`test/server.test.ts`) + +The single test file gained ~520 lines. Five describe blocks: + +| Block | What it covers | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `LiteLLM config` | Parse `LITELLM_MODELS_JSON`, verify preset compat (gpt-5.5 → openai-responses, reasoning=true, thinkingFormat=openai), confirm default thinking is clamped (`high → xhigh` for gpt-5.5). Restores env between tests via a `Map` snapshot (good pattern). | +| `REST surface` | Health, list/create sessions, models endpoint, **provider auth API** (PUT key, list, DELETE — asserts secrets never appear in any response body), **OAuth flow** (start, continue with manual code, complete; asserts access tokens never leak), **OAuth flow reuse** (two starts return the same flowId; `loginCalls === 1`), **OAuth port-conflict error message**, **custom provider CRUD**, **PATCH settings** (happy path + 400 on incomplete pairs), 404 on unknown id, 400 on empty prompt body, OpenAPI doc paths, extension-ui pending/response stubs. | +| `project-scoped runtimes` | Multi-mode route split: confirms `/v1/sessions` 404s when `sessionRoutes: false`, project auth routes 404 when `credentialRoutes: false`, project-scoped sessions are isolated by `X-Appx-Project-Dir`. | +| `bearer auth seam` | 401 without/wrong token, 200 with right token, `/openapi.json` stays open. | +| `SSE` | Connect → "connected to" frame, publish synthetic event, fan-out to two subscribers. Heartbeat path is implicitly covered by the connected-frame timing. | + +**Helpful test infra:** `makeProject()` builds a scratch tmpdir with `.pi/AGENTS.md` and `data/sessions/`. `pickPort()` binds to 0 to grab a free port. The runtime is constructed with a no-op logger to keep test output clean. + +**Coverage gaps worth noting:** + +- No test for `xhigh → high` clamping when switching to a model that doesn't advertise xhigh. +- No test for the `setSessionModelInternal` fallback that auto-picks the new model's default thinking when the previous level is unsupported. +- No test for the SSE replay of pending extension UI requests on reconnect. +- No test for two simultaneous `PATCH settings` while `isStreaming` is true (race). + +### 4.2 Manual verification checklist + +Run `PROJECT_DIR=/some/test/repo npm run dev` in one terminal. Use a second terminal for curl. + +``` +[ ] 1. GET /v1/healthz → { ok: true, channels: {} } +[ ] 2. GET /v1/auth/providers → list includes anthropic, openai, etc. +[ ] 3. PUT /v1/auth/providers/anthropic/api-key { key: "sk-ant-..." } → ok +[ ] 4. GET /v1/auth/providers → anthropic.configured=true, source="stored" +[ ] 5. POST /v1/sessions → returns { id, createdAt } +[ ] 6. GET /v1/sessions/{id}/settings → returns model+thinking metadata +[ ] 7. PATCH .../settings { thinkingLevel: "high" } → 200, level=high +[ ] 8. PATCH .../settings { thinkingLevel: "xhigh" } on a model w/o xhigh + → 200, level clamped to highest supported +[ ] 9. POST .../prompt { text: "hello" } → 200, then GET .../events sees + message_start / text_delta frames within ~5s +[ ] 10. While step 9 is streaming, POST another prompt → succeeds via "steer" +[ ] 11. PATCH .../settings while streaming → 409 conflict +[ ] 12. POST .../abort while streaming → 200, stream emits abort/end events +[ ] 13. DELETE /v1/auth/providers/anthropic → ok +[ ] 14. POST /v1/auth/providers/anthropic/subscription/start → status="auth" + with authUrl. Open the URL, complete login. (Or: paste a fake URL via + /continue to reach error="…" path) +[ ] 15. POST /v1/auth/providers/anthropic/subscription/start a second time + before the first finishes → returns the SAME flowId (reuse path) +[ ] 16. PUT /v1/custom/providers { provider: "litellm-test", baseUrl: ..., + api: "openai-completions", apiKey: "...", models: [{ id: "..." }] } + → 200, listed in /v1/custom/providers, model appears in + /v1/sessions/models with available=true +[ ] 17. GET /openapi.json → contains all 18 paths from the test assertion +[ ] 18. AGENT_SERVER_MODE=multi run, then GET /v1/sessions → 404 + GET /v1/projects/foo/sessions with X-Appx-Project-Dir header → 200 +[ ] 19. AGENT_SERVER_TOKEN=secret restart, GET /v1/sessions without auth → 401 + with `Authorization: Bearer secret` → 200 + GET /openapi.json without auth → 200 (codegen surface stays open) +``` + +For the extension UI bridge, the easiest manual test is to install Pi's +`permission-gate` example via `PI_EXTENSION_PATHS` and trigger a confirm +dialog — the SSE stream should emit an `extension_ui_request` and the +runtime should accept the response POST. + +--- + +## 5. Architecture & Code Pitfalls + +| # | Location | Severity | Problem | Fix sketch | +| --- | ------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `routes.ts:95–101` `settingsErrorStatus` | medium | Maps errors to HTTP codes via `message.includes("not found")`, `"running"`, `"No API key"`. Fragile against runtime message tweaks. | Throw typed errors (e.g. `class SessionNotFoundError extends Error`) and switch on instance. | +| 2 | `routes.ts:741` abort handler | low | Any error from `abortSession` becomes 404, even if the cause is internal. | Pattern-match on the message like settings does, or distinguish via typed errors. | +| 3 | `runtime.ts:911–917` `oauthLoginErrorMessage` | medium | String-matches `EADDRINUSE` from Node's error message. If Node changes the format, the friendly message disappears (the test at `server.test.ts:506` then breaks). | Inspect `error.code` if available; keep substring as fallback. | +| 4 | `runtime.ts:1189–1196` `setSessionModel` | medium | `isStreaming` is checked then `setModel` is called without a lock. Concurrent PATCH + prompt requests could observe stale state. | Add a per-session async mutex around all state mutations. | +| 5 | `runtimeRegistry.ts:76–82` cache | low | Cache key is `context.id` only; replacing a runtime via different `projectDir` orphans old in-memory sessions. | Reject mismatched `projectDir` for the same `id`, or include `projectDir` in the key. Trust assumption is documented but worth hardening. | +| 6 | `runtime.ts:290` `live` map | low | Never evicted. Long-running multi-project hosts grow without bound. | LRU or idle-timeout eviction; preserve session JSONL on disk so reopening is cheap. | +| 7 | `runtime.ts:1078–1081` `writeModelsJson` | low | Truncates and rewrites the entire file on every upsert. Concurrent writes (two PUTs at once) interleave. | Use a per-file mutex or atomic rename (`writeFileSync` to tmp + `renameSync`). | +| 8 | `runtime.ts:708` (createNewSession returns `createdAt`) | low | `createdAt: new Date().toISOString()` is generated client-side here, not by Pi. The `listSessions` merge later sorts by ISO string but on-disk metadata uses Pi's own `info.created`. Sub-second skew between server boot and Pi's `Date.now()` is harmless, but be aware these are two clocks. | Acceptable; document the contract. | +| 9 | `litellm.ts` clamp duplication | low | `clampThinkingLevel` exists here and in `runtime.ts`. | Move to a shared `thinking.ts` helper. | +| 10 | `runtime.ts:368–391` `supportedThinkingLevelsForModel` | low | A reasoning model whose `thinkingLevelMap` sets every level to `null` returns `[]`. The clamping function falls back to `"off"` even though `"off"` was explicitly disabled. | Validate at registration: a reasoning model must support at least one non-null level. | +| 11 | Hardcoded `x-appx-project-dir` header | low | Spelled inline in `server.ts` and `test/server.test.ts`. Easy to typo. | Extract `const APPX_PROJECT_DIR_HEADER = "x-appx-project-dir"` and import. | +| 12 | `routes.ts:708` fire-and-forget prompt | medium | `runtime.sendPrompt(id, text).catch(console.error)` — if it throws synchronously before the SSE loop sees any event, the user gets no signal. | Capture the error and publish it as a synthetic event onto the session channel. | + +--- + +## 6. Fixed Pitfalls + +These were caught during this PR's commit history. Listed because the resulting code looks odd without context. + +> **Problem (`6839e4e`):** Extensions started loading inside `createAgentSession`, but Pi's `createAgentSession` returns _before_ `bindExtensions` finishes. A racing prompt could be sent before extensions were ready. +> **Fix:** `bind()` records `extensionsReady: Promise` (`runtime.ts:646–671`); `sendPrompt` awaits it (`runtime.ts:1231`). + +> **Problem (`edd6d6f`):** Calling `POST /auth/providers/{p}/subscription/start` twice for the same provider tried to start a second Pi `login()`, which triggered `EADDRINUSE` on the OAuth callback port. +> **Fix:** `activeOAuthFlowForProvider` (`runtime.ts:900–909`) returns the existing flow for non-terminal, non-expired flows. Tested at `server.test.ts:387`. + +> **Problem (`aa3851e`):** `GET /v1/sessions/{id}` with an unknown id returned 200 with `messages: []` because `getSessionMessages` couldn't distinguish "no session" from "empty session". +> **Fix:** Return `null` when the session doesn't exist; the route maps `null` → 404 (`routes.ts:606`). + +> **Problem (`5e93fae`):** `npm exec agent-server` failed because `dist/server.js` wasn't marked executable in the npm package, even though `bin` was set in `package.json`. +> **Fix:** Add the shebang `#!/usr/bin/env node` (`server.ts:1`) so npm marks it executable on install. + +> **Problem (`6112c2b`):** Pi SDK floats minor versions; an upstream patch broke `bindExtensions` signature mid-development. +> **Fix:** Pin to `0.75.4` exactly in `package.json:26`. + +--- + +## 7. TODOs & Future Improvements + +**Explicit TODOs in code:** + +- `runtime.ts:290` — `// todo: rename to liveSessions`. Trivial cosmetic. + +**Known limitations (deliberate):** + +- OAuth flows don't survive process restart. Acceptable because flows are short-lived (10 min), but if Appx ever wants resumable login it'll need a small JSON store. +- `models.json` writes aren't atomic. Single-user assumption holds; concurrent UI edits are not a current scenario. +- Extension UI bridge has no audit log. If an extension prompts the user for sensitive input, no record exists outside Pi's own session JSONL. +- Multi-project mode trusts `X-Appx-Project-Dir` header completely. Documented contract: Appx must validate `projectId` before forwarding. Worth re-checking when this is integrated. + +**Forward-looking scaffolding present but inactive:** + +- `projectExtensionPaths: [".pi/extensions/appx-guardrails.ts"]` (`runtimeRegistry.ts:52`) is a forward hook for a future Appx-shipped permission gate. No-op until that file lands. +- `index.ts` re-exports `subscribe`, `publish`, `channelStats` from the SSE broker (`index.ts:34`). This is for hosts that want to publish their own events on session channels (e.g. cron updates, telegram messages — see broker comment). + +**Suggested next steps (post-merge):** + +1. Replace string-match error mapping with typed errors (Pitfall #1, #2, #3). +2. Add an idle-eviction policy to the live-session map (Pitfall #6). +3. Atomic write for `models.json` (Pitfall #7). +4. Extract a `thinking.ts` shared helper for clamp/levels logic (Pitfall #9). +5. Plumb `prompt`-handler errors back as synthetic SSE events (Pitfall #12). +6. Consider adding a small integration test that drives a real Pi extension's `confirm` dialog through the bridge end-to-end. diff --git a/docs/architecture/important/agent-server-layers.md b/docs/architecture/important/agent-server-layers.md new file mode 100644 index 0000000..14937b1 --- /dev/null +++ b/docs/architecture/important/agent-server-layers.md @@ -0,0 +1,190 @@ +# agent-server runtime layers: Registry / Runtime / Session + +How `ProjectRegistry`, `ProjectRuntime`, and `ProjectSession` relate inside a +single agent-server process, and how a request reaches a runtime now that +routing is always project-scoped (there is no `single`/`multi` mode). + +## In simple terms + +Three nested layers, each with one job: + +| Class | "It owns…" | "There is one per…" | +|---|---|---| +| **`ProjectRegistry`** | The shared org-global state (LLM keys, model catalog, credentials service), the **durable project registry** (`projects.json`), and a directory of project runtimes | **process** | +| **`ProjectRuntime`** | Everything scoped to one project (project dir, sessions dir, the loaded extensions/skills/themes for that project, the in-memory map of live sessions) | **project** | +| **`ProjectSession`** | One conversation with the agent — its `AgentSession`, its event stream, its pending extension-UI prompts, prompt/abort/settings ops | **chat session** | + +Said like a Russian doll: **Registry contains Runtimes, Runtime contains +Sessions.** A request always lands on a session, which lives in a runtime, which +is found in the registry. + +You can map it 1:1 to the URL surface: + +- `/v1/auth/*`, `/v1/custom/*` → **Registry** (org-level) +- `/v1/projects` (POST create, GET list), `/v1/projects/{id}` (GET/DELETE) → **Registry** (project lifecycle) +- `/v1/projects/{id}/sessions` (POST/GET list) → **Runtime** (project-level) +- `/v1/projects/{id}/sessions/{sid}/...` → **Session** (conversation-level) + +## Filesystem layout + +Everything lives under one mountable root, `WORKSPACE_DIR`: + +``` +WORKSPACE_DIR/ +├── .pi-global/ # org-global + agent-server state (the Registry tier) +│ ├── auth.json # Pi auth (keys injected from env at boot, in-memory-first) +│ ├── models.json # Pi custom providers +│ ├── projects.json # durable project registry — SOURCE OF TRUTH +│ └── sessions/{id}/ # session transcripts, centralised, namespaced by project id +├── {id}/ # project working dir = app source + config (the Runtime tier) +│ └── .pi/ # AGENTS.md, skills/, extensions/, settings.json (committable) +└── {id2}/ ... +``` + +The Registry's `agentDir` is hardcoded to `WORKSPACE_DIR/.pi-global`. Session +transcripts are deliberately **centralised** under `.pi-global/sessions/{id}/` +rather than inside `{id}/.pi/sessions/`, so each project's `.pi/` stays +config-only (committable) and transcripts live independently on the volume. See +[project-lifecycle-and-workspace-layout.md](./project-lifecycle-and-workspace-layout.md). + +## Static structure + +``` +┌────────────────────────────────────────────────────────────────┐ +│ agent-server process (one per organisation) │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ ProjectRegistry │ │ +│ │ ──────────────────────── │ │ +│ │ • AuthStorage ┐ │ │ +│ │ • ModelRegistry │ shared, process-global │ │ +│ │ • AgentCredentialsService │ │ +│ │ • ProjectStore ──────────► .pi-global/projects.json │ │ +│ │ (durable id → {name, createdAt}; source of truth) │ │ +│ │ │ │ +│ │ • runtimes: Map (lazy cache) │ │ +│ │ ├─ "eventx" ───────► ProjectRuntime "eventx" │ │ +│ │ ├─ "todoapp" ───────► ProjectRuntime "todoapp" │ │ +│ │ └─ ... │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─── ProjectRuntime "eventx" ────────────────────────────┐ │ +│ │ • projectDir = WORKSPACE_DIR/eventx │ │ +│ │ • sessionsDir = WORKSPACE_DIR/.pi-global/sessions/eventx│ │ +│ │ • piDir = WORKSPACE_DIR/eventx/.pi │ │ +│ │ (AGENTS.md, skills/, extensions/, settings.json) │ │ +│ │ • AgentSessionServices (extensions/skills/themes, │ │ +│ │ loaded once per project, reused across sessions) │ │ +│ │ • SessionManager (reads/writes JSONL session files) │ │ +│ │ • sessions: Map │ │ +│ │ ├─ "abc-123" ─► ProjectSession │ │ +│ │ └─ "def-456" ─► ProjectSession │ │ +│ │ │ │ +│ │ exposes: createNewSession() / getSession() / │ │ +│ │ listSessions() │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─── ProjectSession "abc-123" ───────────────────────────┐ │ +│ │ • session: AgentSession (Pi-SDK object, the actual │ │ +│ │ LLM conversation + tool runner) │ │ +│ │ • forwards AgentSessionEvents → sseBroker(sessionId) │ │ +│ │ • pending extension-UI requests (Map) │ │ +│ │ │ │ +│ │ exposes: sendPrompt() / abort() / getMessages() / │ │ +│ │ getModelSettings() / updateModelSettings() / │ │ +│ │ resolveExtensionUiRequest() │ │ +│ └────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +Important properties this layout encodes: + +1. **`AuthStorage` and `ModelRegistry` live in the Registry, not in any Runtime.** + Runtimes *hold references* to them but don't own them. That's the technical + reason a single set of LLM keys covers every project — the registry hands the + same instances to every `ProjectRuntime` it builds. +2. **The `ProjectStore` (`projects.json`) is the source of truth for which + projects exist**, not the in-memory `runtimes` map. The map is a lazy cache of + *built* runtimes; the store is the durable list that survives restarts. +3. **There is no eager runtime.** The registry boots by setting up shared + services and loading `projects.json`; it builds **zero** `ProjectRuntime`s up + front. A runtime is constructed lazily the first time something acts on its + project (`getRuntime(id)`), and cached thereafter. +4. **Runtimes own session *files*; ProjectSessions own session *behaviour*.** The + runtime can list/load sessions from disk without instantiating a + `ProjectSession` for each one (cheap listing). It only constructs a + `ProjectSession` when something actually needs to act on it (`getSession(id)` + lazily reopens, `createNewSession()` makes a fresh one). The + `Map` is the *live* set, not the persisted set. + +## Project lifecycle (Registry tier) + +Projects are **explicit, persisted resources** — there is no implicit creation +on first request, and no project definition smuggled in request headers. + +``` +POST /v1/projects { name: "My App" } + └─ ProjectRegistry.createProject({ name }) + • id = slugify(name) (immutable; registry key, route param, dir name) + • mkdir WORKSPACE_DIR/{id} + • ProjectStore.add({ id, name, createdAt }) → persisted atomically + • returns { id, name, projectDir, createdAt } (runtime built later, lazily) +``` + +- **Idempotent on name.** Re-POSTing the same name (e.g. after a restart) + returns the existing project untouched. A *different* name that slugifies to a + taken id is a genuine collision and gets a short random suffix so both coexist. +- **Boot reconciliation.** On startup the registry rehydrates the project list + from `projects.json`. Runtimes are still built lazily, so rehydration is cheap + (no filesystem walks until a project is actually used). +- **`DELETE /v1/projects/{id}`** evicts the cached runtime, drops the metadata + record, and removes both on-disk locations — the working dir + `WORKSPACE_DIR/{id}/` and the transcripts `.pi-global/sessions/{id}/`. + +## How a session request reaches a Runtime + +Routing is uniform: session routes are mounted at `/v1/projects/:projectId` and +resolve the runtime by a **pure registry lookup** on the path id. + +``` +HTTP request Hono routing Runtime resolution +───────────────────────────────────── ───────────────────────── ──────────────────────────── +GET /v1/projects/eventx/sessions/abc /v1/projects/:projectId projectRuntimeFromRequest(c): + └─ createSessionsApp( registry.getRuntime("eventx") + projectRuntimeFromRequest) │ + ├─ not in projects.json + │ → ProjectNotRegisteredError → 404 + └─ registered + │ (cache miss → build runtime) + ▼ + ProjectRuntime "eventx" + │ + ▼ + ProjectSession "abc" +``` + +- The resolver is a **pure lookup** — it never creates a project as a side + effect. An unknown id raises `ProjectNotRegisteredError`, which the global + error handler maps to `404`. Projects must be created via `POST /v1/projects` + first. +- `registry.runtimes` is populated **lazily**: the first session request for a + registered project builds that project's runtime and caches it. +- The credentials surface (`/v1/auth/*`, `/v1/custom/*`) is mounted on the + registry's `credentials` service directly — credentials are org-global, not + project-scoped, and don't depend on any runtime existing. +- A standalone deployment (e.g. a game spawning a Game-Master and a Tutor agent, + or an eventx-style single app) is just a workspace that happens to hold one or + a few projects, each created explicitly. There is no special "single" path. + +## The mental shortcut + +If you only remember one thing: + +> **Registry is the org. Runtime is the project. Session is the conversation.** +> **Projects are explicit, persisted, and addressed by id in the URL path; a +> session request is a pure lookup of an already-registered runtime.** + +`projectRegistry.ts` owns project identity and the durable registry; +`server.ts` (and its `openapi.ts` mirror) just mounts the credentials app, the +project-lifecycle app, and the session app whose resolver calls +`registry.getRuntime(projectId)`. diff --git a/docs/architecture/important/builder-container-architecture.md b/docs/architecture/important/builder-container-architecture.md new file mode 100644 index 0000000..f48edc6 --- /dev/null +++ b/docs/architecture/important/builder-container-architecture.md @@ -0,0 +1,246 @@ +# Builder Container Architecture + +The canonical "this is what we're building" reference for Appx's single-admin-user agentic app builder. + +## The Goal + +Build a system where: + +1. Builder agents are isolated from the host system +2. Apps the agents create are also isolated from the host +3. All builder agents share one set of LLM credentials +4. Builder agents can deploy apps via containers + +## The Architecture, Drawn Out + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ HOST │ +│ │ +│ Docker (or Podman) — runs ONE outer container │ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ OUTER BUILDER-CONTAINER (unprivileged) │ │ +│ │ — security boundary against the host │ │ +│ │ — holds LLM credentials in memory │ │ +│ │ — has rootless podman + agent-server installed │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ agent-server (one Node.js process) │ │ │ +│ │ │ • AuthStorage (LLM keys, runtime-only) │ │ │ +│ │ │ • ModelRegistry │ │ │ +│ │ │ • ProjectRegistry │ │ │ +│ │ │ ├─ ProjectRuntime: project "eventx" │ │ │ +│ │ │ │ └─ ProjectSession (the builder agent for │ │ │ +│ │ │ │ eventx — modifies code, runs podman) │ │ │ +│ │ │ │ │ │ │ +│ │ │ ├─ ProjectRuntime: project "todoapp" │ │ │ +│ │ │ │ └─ ProjectSession (todoapp's builder agent)│ │ │ +│ │ │ │ │ │ │ +│ │ │ └─ ProjectRuntime: project "crm" │ │ │ +│ │ │ └─ ProjectSession │ │ │ +│ │ └────────────────────┬────────────────────────────────┘ │ │ +│ │ │ bash tool runs podman │ │ +│ │ ┌────────────────────▼────────────────────────────────┐ │ │ +│ │ │ rootless podman │ │ │ +│ │ │ storage: ~/.local/share/containers/ │ │ │ +│ │ └────────────────────┬────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ /workspace/ ← projects mounted here │ │ +│ │ ├── eventx/ │ │ +│ │ ├── todoapp/ │ │ +│ │ └── crm/ │ │ +│ │ │ │ +│ │ ┌──── inner containers spawned by builder agents ───┐ │ │ +│ │ │ │ │ │ +│ │ │ ┌────────────────┐ ┌────────────────┐ │ │ │ +│ │ │ │ eventx-app │ │ eventx-db │ │ │ │ +│ │ │ │ (built/run by │ │ (built/run by │ │ │ │ +│ │ │ │ eventx agent) │ │ eventx agent) │ │ │ │ +│ │ │ └────────────────┘ └────────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ ┌────────────────┐ │ │ │ +│ │ │ │ todoapp-app │ (todoapp agent's outputs) │ │ │ +│ │ │ └────────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ No keys here. Don't share namespaces with │ │ │ +│ │ │ the builder. Visible only inside outer. │ │ │ +│ │ └───────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + + Trust zones: + • Host: trusted, doesn't run app code + • Outer container: trusted with credentials, runs builder agents + • Inner containers: untrusted, run LLM-generated code, no creds +``` + +## Component Mapping + +| Concept | What it maps to in code | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Unprivileged builder-container | Outer container, no `--privileged`, runs as non-root user | +| running agent-server | One Node.js process inside outer container | +| spins up builder agents for each project | `ProjectRegistry.createProject()` registers a project; `getRuntime()` lazily builds a `ProjectRuntime` per project; each runtime owns a `Map` | +| modify app source | `read`/`write`/`edit` tools on `/workspace//` | +| create app containers using rootless podman | `bash` tool runs `podman build` / `podman run` inside the outer container | +| isolate builder agents and apps from host | Outer container is the host-side security boundary | +| share auth between builder agents | All `ProjectRuntime`s in the registry share the same `AuthStorage` and `ModelRegistry` (already designed this way in `projectRegistry.ts`) | + +## Two Subtle Points + +### Point 1: "Spins up builder agents" = sessions, not processes + +In agent-server's design, all "builder agents" are **`ProjectSession` instances within the same `agent-server` Node.js process** — not separate processes. Each `ProjectSession` wraps an `AgentSession` plus per-session ExtensionUIContext / SSE plumbing; sessions belonging to the same project share a `ProjectRuntime`, and all projects share the process-global `AuthStorage` / `ModelRegistry`. They differ only in: + +- Which project directory they operate over (`projectDir`) +- Which session file persists their conversation +- Which extensions/skills they have loaded + +```typescript +// What "spins up a builder agent for a project" actually is: +const project = registry.createProject({ name: "eventx" }); // id "eventx", dir WORKSPACE_DIR/eventx +const runtime = await registry.getRuntime(project.id); +const session = await runtime.createNewSession(); +await session.sendPrompt("scaffold a Next.js app"); +``` + +There's no fork, no new process, no separate auth context. It's a `Map` lookup, and the runtime owns a `Map`. + +**Why this is fine:** in the single-admin-user scenario, all projects belong to the same human. There's no inter-tenant trust boundary to enforce. Sharing one process is the natural fit. + +**When it stops being fine:** if multiple end-users (Alice, Bob, etc.) are added later, "builder agents share a process" means a bug in Alice's session could potentially interfere with Bob's. At that point, graduate to per-user outer containers or per-user systemd units (the patterns from `systemd-isolation.md`). + +For now, "spins up builder agents" is a logical operation — calling `createProject(...)` once to register the project, then `getRuntime(...)` to get (or lazily build) the `ProjectRuntime` and `createNewSession()` to get a `ProjectSession` — not a process operation. + +### Point 2: Auth sharing happens automatically + +Because all builder agents are sessions within one process, sharing auth is trivial: + +```typescript +// At outer container startup (agent-server bootstrap): +authStorage.setRuntimeApiKey("anthropic", process.env.ANTHROPIC_API_KEY); +authStorage.setRuntimeApiKey("openai", process.env.OPENAI_API_KEY); +// That's it. + +// Every project's ProjectRuntime, every session, every LLM call: +// uses these in-memory keys. No further plumbing needed. +``` + +The keys come in via the `docker run -e ANTHROPIC_API_KEY=...` flag on the outer container, get pushed to `AuthStorage` once at startup, and every builder agent uses them naturally because they're all reading from the same `AuthStorage` instance. + +**What this means for credentials never reaching app containers:** when the builder agent runs `podman run myapp`, the inner container inherits whatever env vars the agent passes via `-e ...`. The agent doesn't (and shouldn't) pass `ANTHROPIC_API_KEY` to the inner app. Even if the LLM tried to be clever and write the key into a Dockerfile, the key would only be in _the file_, not in the running app's environment unless deliberately wired in. + +For defense in depth, configure agent-server's bash tool with a `spawnHook` that strips LLM keys from the env before running any command — but in practice it doesn't tend to happen because the keys aren't in env vars at the bash level; they're in the agent-server process's heap. + +## Runtime Walkthrough + +Concrete walkthrough of "user creates eventx and prompts the agent": + +``` +1. User (admin) → POST /v1/projects { name: "eventx" } + agent-server slugifies the name to id "eventx", creates WORKSPACE_DIR/eventx, + and persists the project to .pi-global/projects.json (idempotent on name) + +2. User → POST /v1/projects/eventx/sessions + agent-server: (await registry.getRuntime("eventx")).createNewSession() + → Lazily builds the ProjectRuntime for eventx (or returns the cached one) + → Creates AgentSession bound to that runtime + → Returns sessionId + +3. User → POST /v1/projects/eventx/sessions/:id/prompt + body: "scaffold a Next.js app and run it on port 3000" + +4. agent-server's ProjectSession.sendPrompt() → AgentSession.prompt() + → LLM call (using shared AuthStorage's anthropic key) + → LLM emits tool calls: + - write Dockerfile → writes to /workspace/eventx/Dockerfile + - bash "podman build -t..." → outer container's podman builds image + - bash "podman run -d..." → outer container's podman starts inner container + → Each tool result feeds back into the LLM + → Tool execution events stream over SSE to the user + +5. User → curl http://localhost:3000 + Host port 3000 → outer container port 3000 → inner container :3000 → Next.js app +``` + +No host-level work happens for any of this beyond running the outer container. **All multi-project orchestration, auth, building, deploying happens inside the outer container.** + +## What Already Exists + +- ✅ `ProjectRegistry` — multi-project, with a durable `projects.json` registry +- ✅ Shared `AuthStorage` / `ModelRegistry` across projects +- ✅ Per-session HTTP+SSE API +- ✅ Pluggable bash via `BashOperations` / `customTools` +- ✅ Project lifecycle + scoped routes (`POST /v1/projects`, `/v1/projects/:id/sessions/...`) + +## What Needs to Be Built + +1. **The outer container's Dockerfile** — Ubuntu/Alpine + podman + nodejs + agent-server (~10 lines, draft in `rootless-podman-isolation.md`) +2. **A run script / docker-compose** that launches the outer container with the right flags (`--device /dev/fuse`, port forwards, volume mount, env vars) +3. **Project provisioning logic** — `POST /v1/projects { name }` already creates `WORKSPACE_DIR//` and registers the project; provisioning is just calling that endpoint (plus any product-specific scaffolding the caller layers on top) +4. **System prompt for the builder agent** — telling it that `podman` is available, where projects live, how to expose ports +5. **(Optional) An idle-eviction sweep** — if many projects exist and stopping unused `ProjectRuntime`s would free memory; not needed for one admin user + +That's it. Maybe 1-2 days of work for the outer container + provisioning, plus prompt engineering iteration on point 4. + +## What This Architecture Buys You + +| Goal | How it's met | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **Isolate builder agents and apps from host** | Outer container is unprivileged + user-namespaced. Inner containers are nested in the outer's namespaces. Host can't be touched. | +| **Share auth between builder agents** | All sessions live in one process with one shared `AuthStorage`. Trivial. | +| **Builder agents can modify code** | Pi's `write`/`edit`/`read` tools, with `/workspace` mounted from host. | +| **Builder agents can spin up app containers** | `bash` tool runs `podman` commands. Inner containers are children of the outer. | +| **App containers don't have LLM keys** | Keys live in `AuthStorage` in agent-server's memory. They never enter the env of inner containers unless deliberately passed. | +| **One sandbox to manage, scale, debug** | One outer container = one PID to monitor on the host. | +| **Single-admin scenario is simple** | No multi-user complexity, no per-user systemd units, no namespace-per-tenant. | + +## Known Limitations + +These aren't blockers for the stated case, just worth knowing: + +1. **All projects share the outer container's memory and CPU.** A runaway build in eventx can starve todoapp's session. Add `--memory` and `--cpus` limits on the outer container; rely on user behavior within. +2. **All projects share the outer container's filesystem quota.** One project filling `/workspace` affects everyone. Disk quota or per-project mount points if it matters. +3. **No process-level isolation between projects.** A bug in agent-server affects all projects. For single-admin, fine. +4. **First-time podman storage init is slow.** Add `podman info` to the entrypoint to warm up. +5. **Inner container ports must be allocated.** Either expose a port range (`-p 3000-3010:3000-3010`) and let the agent pick, or have a registry that hands out ports. The latter scales better. +6. **Outer container restart kills inner containers.** Inner Podman state lives in the outer container's filesystem. If you `docker restart builder`, all running apps die. Mount Podman storage as a volume if you want persistence: `-v podman-storage:/home/builder/.local/share/containers`. + +None of these are dealbreakers; just trade-offs to be aware of. + +## Escalation Paths (For Later) + +When the single-admin scenario outgrows this design, here's how the architecture composes: + +| Future need | Escalation | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Multiple end-users with strong isolation | One outer container per user; appx routes by user → container (see `systemd-isolation.md`) | +| Cross-host scaling | Each outer container becomes a k8s pod; namespace per user (see `hosted-platform-migration.md` if added later) | +| Stronger isolation for hostile workloads | Sysbox runtime for the outer container; or microVMs (Firecracker/Kata) | +| Anonymous public users (untrusted) | Pattern 5 from `builder-agent-isolation.md`: platform Build/Deploy API with ephemeral sandboxes | + +None of these invalidate this design — they layer on top. The "one outer container with agent-server + rootless podman + projects mounted" core pattern remains the unit of deployment. + +## TL;DR + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ONE outer container, unprivileged, user-namespaced │ +│ ├── ONE agent-server process │ +│ │ ├── shared AuthStorage (LLM keys live here) │ +│ │ ├── per-project ProjectRuntime │ +│ │ └── per-project AgentSession (the "builder agent") │ +│ ├── rootless podman │ +│ └── inner containers (the actual apps the agents build) │ +└─────────────────────────────────────────────────────────────┘ +``` + +This satisfies all four requirements: + +1. ✅ Builder agents isolated from host (outer container boundary) +2. ✅ Apps isolated from host (inner containers nested in outer) +3. ✅ Shared auth across builder agents (one AuthStorage in one process) +4. ✅ Builder agents can deploy apps via containers (podman in their bash tool) + +For one admin user with many projects, **this is the entire architecture**. Everything more elaborate — per-user systemd units, k8s namespaces, Sysbox, microVMs — is escalation paths for when this is outgrown. None of those changes invalidate the design; they layer on top. diff --git a/docs/architecture/important/project-lifecycle-and-workspace-layout.md b/docs/architecture/important/project-lifecycle-and-workspace-layout.md new file mode 100644 index 0000000..878fabe --- /dev/null +++ b/docs/architecture/important/project-lifecycle-and-workspace-layout.md @@ -0,0 +1,127 @@ +# Project Lifecycle & Workspace Layout + +Status: **adopted** (supersedes the header-driven `projectRuntimeFromRequest` model) + +## Why this exists + +The previous design created a `ProjectRuntime` lazily as a side effect of the +first session request, reading the project's *definition* (`x-appx-project-dir`, +`x-appx-project-name`) from trusted proxy headers on **every** request. That +conflated two distinct concepts: + +| Concept | What it is | Where it used to live | Where it lives now | +|---|---|---|---| +| Project **identity** ("which project") | `id` | URL path param | URL path param (unchanged) | +| Project **definition** ("what it is, where it lives") | `name`, `projectDir` | smuggled in headers, per request | request body of a one-time `POST /v1/projects`, persisted | + +agent-server is the orchestration core of the +[builder-container architecture](./builder-container-architecture.md): it spawns +builder agents per project and is reused standalone (e.g. the LanQuest game +spawns a Game-Master agent and a Tutor agent through the same surface). To be a +self-contained orchestrator it must **own** what a project is and where it lives, +and that ownership must survive a container restart. + +## Decisions + +1. **`id` is the slug.** `id = slugify(name)` (with a short random suffix only on + collision). It is immutable, and is simultaneously the registry key, the route + param, and the on-disk directory name. `name` is a free-form, mutable display + label stored only in metadata — it never touches the filesystem. A rename never + moves a directory. + +2. **Name-only input.** The API accepts `name`, never a `projectDir`. The + directory is derived by convention. Because the only path input is a slugified + name, path traversal is structurally impossible (OWASP). + +3. **`WORKSPACE_DIR` replaces `PROJECT_DIR`.** One root holds everything: + + ``` + WORKSPACE_DIR/ + ├── .pi-global/ # org-global + agent-server state + │ ├── auth.json # Pi auth (keys are injected from env at boot, + │ │ # in-memory-first; this file is not the secret of record) + │ ├── models.json # Pi custom providers + │ ├── projects.json # agent-server project registry (SOURCE OF TRUTH) + │ └── sessions/ + │ └── {id}/ # session JSONL transcripts, namespaced by project id + ├── {id}/ # project working dir = app source + config + │ └── .pi/ # AGENTS.md, skills/, extensions/, settings.json (committable) + └── {id2}/ ... + ``` + + - `agentDir` is hardcoded to `WORKSPACE_DIR/.pi-global`. `GLOBAL_AGENT_DIR` is + removed. + - **Sessions are centralised** under `.pi-global/sessions/{id}/` rather than + `{id}/.pi/sessions/`. This separates *config* (input, committable, lives with + the project) from *transcripts* (runtime output). Deleting a project must + remove **both** locations. + +4. **Single mode is collapsed.** There is no `AGENT_SERVER_MODE`. Routing is always + project-scoped (`/v1/projects/{id}/...`). A "standalone" deployment is just a + workspace that happens to hold one project. **No project is auto-created** — + callers create their projects explicitly. + +5. **Idempotent registration + boot reconciliation.** `projects.json` is the + source of truth. On boot the registry rehydrates from it (metadata only; + runtimes are still built lazily on first use). `POST /v1/projects` is an + idempotent upsert: if the slug already exists (e.g. after a restart, the + upstream caller re-POSTs), nothing is recreated — the existing, already + initialised project is returned unchanged. Writes to `projects.json` are atomic + (temp file + `rename`). + +6. **Reserved slugs.** `.pi-global` is reserved; empty/leading-dot slugs are + rejected. + +## HTTP surface + +Mounted under `/v1`: + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/v1/projects` | Create-or-get a project. Body `{ "name": string }`. Returns `{ id, name, projectDir, createdAt }`. Idempotent on the derived slug. | +| `GET` | `/v1/projects` | List registered projects. | +| `GET` | `/v1/projects/{id}` | Get one project's metadata. 404 if unknown. | +| `DELETE` | `/v1/projects/{id}` | Evict the runtime, drop the metadata entry, and remove `WORKSPACE_DIR/{id}/` + `.pi-global/sessions/{id}/`. | +| `*` | `/v1/projects/{id}/sessions...` | Session routes. Resolve the runtime by path `id` via a **pure lookup**; `404 project not registered` if the project was never created. No more `x-appx-*` headers, no lazy creation. | + +## Persistence & containers + +- Mount `WORKSPACE_DIR` as a **named Docker volume**. Both project working data + (`{id}/.pi/`, app source) and global state (`.pi-global/`) then survive + `docker rm` / image upgrades with no code involvement. +- **LLM credentials are not persisted to the volume.** They are injected via env + at startup and held in `AuthStorage` in memory (see builder-container doc). +- **App/agent domain state is not agent-server's concern.** LanQuest's inventory, + game state, and "user progress memories" stored as rows belong to the app's own + DB; the agent touches them through the app's CLI/tools. The only agent-owned + memory is the session transcript, which lives in `.pi-global/sessions/{id}/` on + the volume. + +## How appx integrates + +appx demotes from "owner of project filesystem layout" to a **control plane** +keyed by the shared project id: + +- appx project **names already satisfy the slug grammar** + (`^[a-z][a-z0-9-]{0,61}[a-z0-9]$`), so `slugify(name) == name`. agent-server's + `id` therefore equals appx's project **name**, and the proxy uses `proj.Name` + as the agent-server project id in the path. +- `Manager.Create` calls `POST /v1/projects { name }` (agent-server creates the + dir + registers + persists), then appx layers its own product concerns + (port/subdomain assignment in appx's SQLite, git init, scaffolding) into the + returned `projectDir`. +- `Manager.Delete` calls `DELETE /v1/projects/{name}`. +- On boot, appx calls `Manager.ReconcileAgentProjects`, which idempotently + re-registers every known project. This registers projects that predate + agent-server ownership and makes an agent-server restart transparent (the + in-memory registry is rebuilt from appx's DB without operator action). +- The reverse proxy **stops injecting** `X-Appx-Project-Dir` / `X-Appx-Project-Name`; + the path's project id is sufficient because agent-server resolves the directory + from its own persisted registry. +- appx keeps a SQLite row per project only for things agent-server has no business + knowing (assigned port, subdomain, owning user, health). Two bounded contexts + sharing a key is intentional, not duplication. + +For deployments where appx and agent-server share a host/volume, appx's +`projectRoot` must equal agent-server's `WORKSPACE_DIR` so appx's scaffolding and +agent-server's directory ownership refer to the same path. diff --git a/docs/architecture/other/agent-session-runtime-analysis.md b/docs/architecture/other/agent-session-runtime-analysis.md new file mode 100644 index 0000000..ee456a2 --- /dev/null +++ b/docs/architecture/other/agent-session-runtime-analysis.md @@ -0,0 +1,105 @@ +# AgentSessionRuntime: Do We Need It? + +TL;DR analysis of Pi's `AgentSessionRuntime` and what agent-server should and shouldn't adopt from it. + +**Reference:** [`/Users/max/misc/pj/misc/agents/pi/packages/coding-agent/src/core/agent-session-runtime.ts`](/Users/max/misc/pj/misc/agents/pi/packages/coding-agent/src/core/agent-session-runtime.ts) + +## What It Is + +A wrapper around `AgentSession` that owns the **single "current session"** for one Pi invocation. Manages the lifecycle of replacing that session via `/new`, `/resume`, `/fork`, `/import`. Used by `interactive` and `rpc` modes. + +Owns: one mutable `session`, one `services` bundle, optional rebind/teardown callbacks for the host. + +## Verdict for Agent-Server + +**We don't need the class. We do want a handful of patterns from inside it.** + +Why: `AgentSessionRuntime`'s reason to exist is single-session-replacement (`teardownCurrent` + `apply` on every switch/new/fork). Our model is multi-session-concurrent (`Map`, route by id). Most of its surface is dead weight for us; the rest is replicable in ~10 lines per case. + +## Full Surface, Categorized + +| Category | Members | Useful? | +|---|---|---| +| **Session-replacement lifecycle** | `switchSession`, `newSession`, `fork`, `importFromJsonl` | ❌ Wrong semantics (replace vs add). Reimplement directly via `SessionManager` when needed | +| **Extension hook orchestration** | `emitBeforeSwitch`, `emitBeforeFork`, `emitSessionShutdownEvent` on teardown | ⚠️ **Hooks valuable, wrapping not.** Call `session.extensionRunner.emit(...)` directly | +| **Host callbacks** | `setRebindSession`, `setBeforeSessionInvalidate` | ❌ Both exist for the single-current-session model and TUI sync teardown. Irrelevant for HTTP/SSE | +| **Diagnostics & fallback messaging** | `diagnostics`, `modelFallbackMessage` | ✅ **Adopt.** Real UX wins. We currently discard these | +| **CWD transition handling** | Recreates `AgentSessionServices` on cwd change inside `createRuntime` factory | ❌ Per-project runtime fixes cwd per session; never triggered | +| **Cleanup** | `dispose()` emits `session_shutdown` then disposes session | ✅ **Adopt the pattern.** We currently skip both | + +## AgentSessionServices: Bundle vs Members + +The bundle exists to make cwd transitions atomic — irrelevant for us. + +| Member | Status in agent-server | +|---|---| +| `cwd`, `agentDir` | Already on `AgentRuntime` | +| `authStorage`, `modelRegistry` | Shared on `ProjectRegistry` | +| `resourceLoader` | Created per session via `makeResourceLoader()` | +| `settingsManager` | Not used; could enable future project-settings API | +| `diagnostics` | **Currently dropped on the floor** — should surface | + +**Conclusion:** The struct adds no value. The members are already where they need to be; just capture the two we miss (`diagnostics`, optionally `settingsManager`). + +## Concrete Gaps to Close (Without Adopting the Class) + +These are ~10-line fixes worth doing regardless of architecture path: + +### 1. Emit `session_shutdown` on session dispose + +We currently `unsubscribe()` and stop tracking, but never call `session.dispose()` or fire the extension shutdown event. Stateful extensions never get cleanup signal. Becomes a real leak if we ever evict idle sessions. + +```typescript +// Add to AgentRuntime +async disposeSession(id: string): Promise { + const entry = this.live.get(id); + if (!entry) return; + await emitSessionShutdownEvent(entry.session.extensionRunner, { + type: "session_shutdown", + reason: "quit", + }); + entry.unsubscribe(); + entry.session.dispose(); + this.live.delete(id); +} +``` + +### 2. Capture and expose `diagnostics` + `modelFallbackMessage` + +Today we destructure only `{ session }` from `createAgentSession()`. The full result has both diagnostic fields. Capture them on `LiveSession` and surface via API. + +UX value: "3 extensions failed to load", "default model unavailable, using fallback". + +### 3. Emit `session_before_fork` if/when we add fork + +When implementing `forkSession`, give extensions a chance to veto: + +```typescript +const result = await session.extensionRunner.emit({ + type: "session_before_fork", + entryId, + position: "before", +}); +if (result?.cancel === true) return { cancelled: true }; +``` + +Currently no extension uses this hook, but it's the right contract. + +## What We'd Pay to Adopt the Whole Class + +If we restructured around `AgentSessionRuntime` (one per session or one per project): + +- ~1-2 weeks refactor +- New chat/runtime lifecycle to design +- Reservation registry to prevent same-JSONL-in-two-runtimes (Pi has no lock; concurrent writes silently fork the session tree — see `agent-session-runtime.ts` analysis in extension-ui-implementation-comparison.md) +- Carrying `setRebindSession`/`setBeforeSessionInvalidate` ceremony we'd never use + +What we'd get: ~15 lines of fork code "for free", and the four extension hooks fired in the right places. All replicable directly in less code than the wrapper costs. + +## Bottom Line + +**Architecture:** Keep `AgentRuntime` (multi-session manager, rename to `ProjectRuntime` for clarity vs Pi's `AgentSessionRuntime`). Don't wrap sessions in `AgentSessionRuntime`. + +**Patterns to adopt:** Fire `session_shutdown` on dispose; capture diagnostics; emit `session_before_fork` when adding fork. These are hygiene fixes, not architecture changes. + +**Strategic stance:** We are the multi-session analogue of `AgentSessionRuntime`, not a consumer of it. Same shape (lifecycle owner over `AgentSession`), different concurrency model (N concurrent vs 1 current). diff --git a/docs/architecture/other/extension-ui-implementation-comparison.md b/docs/architecture/other/extension-ui-implementation-comparison.md new file mode 100644 index 0000000..0bf1e57 --- /dev/null +++ b/docs/architecture/other/extension-ui-implementation-comparison.md @@ -0,0 +1,566 @@ +# Extension UI Implementation: RPC Mode vs Agent-Server + +## Overview + +Both RPC mode and agent-server implement Pi's `ExtensionUIContext` interface, but with different architectural patterns driven by their concurrency models: + +- **RPC mode**: Single "current session" model → one global ExtensionUIContext +- **Agent-server**: N concurrent sessions → ExtensionUIContext per session + +This document analyzes the implementation differences and validates agent-server's design choices. + +## Implementation Location Comparison + +### RPC Mode + +**File:** `/Users/max/misc/pj/misc/agents/pi/packages/coding-agent/src/modes/rpc/rpc-mode.ts` + +```typescript +export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + // Closure scope - shared across entire RPC process + const pendingExtensionRequests = new Map(); + + const output = (obj) => { + writeRawStdout(serializeJsonLine(obj)); + }; + + // Create context once, in function scope + const createExtensionUIContext = (): ExtensionUIContext => ({ + select: (title, options, opts) => + createDialogPromise(opts, undefined, + { method: "select", title, options, timeout: opts?.timeout }, + (r) => "cancelled" in r ? undefined : r.value + ), + confirm: (title, message, opts) => + createDialogPromise(opts, false, + { method: "confirm", title, message, timeout: opts?.timeout }, + (r) => "cancelled" in r ? false : r.confirmed + ), + // ... other methods + }); + + // Bind to session + const rebindSession = async () => { + session = runtimeHost.session; + await session.bindExtensions({ + uiContext: createExtensionUIContext(), // ← Same context factory + commandContextActions: { ... }, + onError: (err) => { output({ type: "extension_error", ... }); } + }); + }; +} +``` + +**Key characteristics:** +1. ✅ **Function scope** - All state lives in `runRpcMode()` closure +2. ✅ **Process-global state** - One `pendingExtensionRequests` map +3. ✅ **Shared output channel** - One `output()` function writes to stdout +4. ✅ **Session rebinding** - Same context factory reused when switching sessions + +### Agent-Server + +**File:** `src/runtime.ts` + +```typescript +export class AgentRuntime { + private readonly live = new Map(); + private readonly pendingExtensionUi = new Map(); + + // Instance method - creates session-scoped context + private createExtensionUiContext(sessionId: string): ExtensionUIContext { + return { + select: (title, options, opts) => + this.createDialogPromise( + sessionId, // ← Session-scoped! + opts, + undefined, + { method: "select", title, options, timeout: opts?.timeout }, + (response) => ("cancelled" in response ? undefined : response.value), + ), + confirm: (title, message, opts) => + this.createDialogPromise( + sessionId, // ← Session-scoped! + opts, + false, + { method: "confirm", title, message, timeout: opts?.timeout }, + (response) => ("cancelled" in response ? false : response.confirmed), + ), + // ... other methods + }; + } + + // Bind to session + private bind(session: AgentSession): void { + const id = session.sessionId; + const extensionsReady = session.bindExtensions({ + uiContext: this.createExtensionUiContext(id), // ← Session-specific + commandContextActions: this.extensionCommandActions(session), + onError: (err) => { + publish(id, { type: "extension_error", ... }); + }, + }); + this.live.set(id, { session, unsubscribe, boundAt, extensionsReady }); + } +} +``` + +**Key characteristics:** +1. ✅ **Class scope** - State lives in `AgentRuntime` instance +2. ✅ **Per-session context** - Each session gets `createExtensionUiContext(sessionId)` +3. ✅ **Session-routed output** - `publish(sessionId, event)` routes to correct SSE clients +4. ✅ **Concurrent binding** - Multiple sessions bound simultaneously + +## Concurrency Model Differences + +### RPC Mode: Sequential Session Model + +``` +Time ──────────────────────────────────────────────► + +┌─────────────────────┐ Switch ┌─────────────────────┐ +│ Session A │ ──────► │ Session B │ +│ (current session) │ Unbind │ (new current) │ +│ │ Rebind │ │ +└─────────────────────┘ └─────────────────────┘ + ▲ ▲ + │ │ + ONE context ONE context + (rebound to B) (same factory) +``` + +**RPC process state at any moment:** +```typescript +// Single global state +let session = runtimeHost.session; // ← ONE current session +const pendingExtensionRequests = new Map(); // ← All requests for current session +const createExtensionUIContext = () => ({ ... }); // ← Factory reused on switch +``` + +When switching sessions: +```typescript +// 1. Teardown +await session.dispose(); + +// 2. Switch +await runtimeHost.switchSession("other.jsonl"); + +// 3. Rebind (same context, new session) +session = runtimeHost.session; +await session.bindExtensions({ + uiContext: createExtensionUIContext(), // ← Same factory, bound to new session + ... +}); +``` + +### Agent-Server: Concurrent Session Model + +``` +Time ──────────────────────────────────────────────► + +┌─────────────────────┐ +│ Session A │ ◄─── Client 1 POST/GET +│ Context A │ +└─────────────────────┘ + +┌─────────────────────┐ +│ Session B │ ◄─── Client 2 POST/GET +│ Context B │ +└─────────────────────┘ + +┌─────────────────────┐ +│ Session C │ ◄─── Client 3 POST/GET +│ Context C │ +└─────────────────────┘ +``` + +**Agent-server state at any moment:** +```typescript +// N concurrent sessions +private readonly live = new Map(); +// ├─ "session-a" → { session, context: createExtensionUiContext("session-a") } +// ├─ "session-b" → { session, context: createExtensionUiContext("session-b") } +// └─ "session-c" → { session, context: createExtensionUiContext("session-c") } + +private readonly pendingExtensionUi = new Map(); +// ├─ "req-uuid-1" → { sessionId: "session-a", request, resolve } +// ├─ "req-uuid-2" → { sessionId: "session-b", request, resolve } +// └─ "req-uuid-3" → { sessionId: "session-a", request, resolve } // Another for A +``` + +When handling HTTP requests: +```typescript +// POST /projects/abc/sessions/session-a/prompt +const session = await this.ensureSession("session-a"); // ← Get or create +await session.prompt(text); // ← Session A keeps running + +// POST /projects/abc/sessions/session-b/prompt (concurrent!) +const session = await this.ensureSession("session-b"); // ← Different session +await session.prompt(text); // ← Session B runs in parallel +``` + +## Extension UI Request Routing + +### RPC Mode: Implicit Routing (Current Session Only) + +```typescript +// Extension calls ui.select() +await session.extensionRunner.uiContext.select("Pick one", ["A", "B"]); + +// Flows to: +const createExtensionUIContext = () => ({ + select: (title, options, opts) => + createDialogPromise(opts, undefined, + { method: "select", title, options }, + (r) => r.value + ) +}); + +function createDialogPromise(...) { + const id = crypto.randomUUID(); + + // Register in closure-scoped map (implicitly for current session) + pendingExtensionRequests.set(id, { resolve, reject }); + + // Write to stdout + output({ type: "extension_ui_request", id, method: "select", ... }); + + return promise; +} + +// Client response comes in via stdin: +// {"type":"extension_ui_response","id":"","value":"A"} + +// Lookup in single global map +const pending = pendingExtensionRequests.get(response.id); +if (pending) { + pending.resolve(response); // ← Completes the promise +} +``` + +**Why this works:** +- ONE current session → only one session's extensions can emit UI requests at a time +- No ambiguity about which session a request belongs to +- Single stdin/stdout pair → natural serialization + +### Agent-Server: Explicit Routing (Session ID Required) + +```typescript +// Extension calls ui.select() +await session.extensionRunner.uiContext.select("Pick one", ["A", "B"]); + +// Flows to: +private createExtensionUiContext(sessionId: string): ExtensionUIContext { + return { + select: (title, options, opts) => + this.createDialogPromise( + sessionId, // ← Captures session ID in closure! + opts, + undefined, + { method: "select", title, options }, + (response) => response.value + ) + }; +} + +private createDialogPromise( + sessionId: string, // ← Session context + opts, + defaultValue, + request, + mapResponse +): Promise { + const id = randomUUID(); + + const pending: PendingExtensionUiRequest = { + sessionId, // ← Store which session this request belongs to + request: { type: "extension_ui_request", id, ...request }, + resolve: (response) => { + cleanup(); + resolve(mapResponse(response)); + }, + }; + + // Register in class-scoped map (tagged with sessionId) + this.pendingExtensionUi.set(id, pending); + + // Publish to SSE broker (routes to all clients watching this session) + this.publishExtensionUiRequest(sessionId, pending.request); + + return promise; +} + +// Client response via HTTP: +// POST /projects/abc/sessions/session-a/extension-ui/req-123/response +// {"value":"A"} + +public resolveExtensionUiRequest(sessionId: string, requestId: string, response: ExtensionUiResponse): boolean { + const pending = this.pendingExtensionUi.get(requestId); + + // Verify sessionId matches (prevents cross-session hijacking) + if (!pending || pending.sessionId !== sessionId) return false; + + pending.resolve(response); // ← Completes the promise for the right session + return true; +} +``` + +**Why this is necessary:** +- N concurrent sessions → multiple sessions' extensions can emit UI requests simultaneously +- Need to route response back to correct session's waiting extension +- Multiple SSE clients → need to know which session to broadcast to + +## State Management Comparison + +### RPC Mode: Closure Scope + +```typescript +export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + // ───────────────────────────────────────────────── + // Closure scope - accessible to all nested functions + // ───────────────────────────────────────────────── + + let session = runtimeHost.session; // Mutable: updated on switch + let unsubscribe: (() => void) | undefined; + + const pendingExtensionRequests = new Map(); // Request correlation + const signalCleanupHandlers: Array<() => void> = []; // SIGTERM handlers + let shutdownRequested = false; + let shuttingDown = false; + + const output = (obj) => { ... }; // Writes to stdout + const createDialogPromise = (...) => { ... }; // Accesses pendingExtensionRequests + const createExtensionUIContext = () => ({ ... }); // Accesses createDialogPromise + const rebindSession = async () => { ... }; // Accesses session, createExtensionUIContext + + // All functions form a closure over shared state + // ───────────────────────────────────────────────── +} +``` + +**Characteristics:** +- ✅ Natural JavaScript pattern for single-context apps +- ✅ Clear lifetime - state dies when function returns (never) +- ✅ No need for explicit scoping - closure captures everything +- ⚠️ Not extensible to multi-session without major refactoring + +### Agent-Server: Class Instance Scope + +```typescript +export class AgentRuntime { + // ───────────────────────────────────────────────── + // Instance members - accessible to all methods + // ───────────────────────────────────────────────── + + private readonly live = new Map(); // N sessions + private readonly pendingExtensionUi = new Map(); + private readonly projectDir: string; + private readonly sessionsDir: string; + private readonly authStorage: AuthStorage; + private readonly modelRegistry: ModelRegistry; + // ... other config + + private bind(session: AgentSession): void { ... } + private createDialogPromise(sessionId: string, ...): Promise { ... } + private createExtensionUiContext(sessionId: string): ExtensionUIContext { ... } + private publishExtensionUiRequest(sessionId: string, request): void { ... } + + // Methods operate on class state + per-session routing + // ───────────────────────────────────────────────── +} +``` + +**Characteristics:** +- ✅ Handles multiple sessions naturally (Map-based) +- ✅ Explicit lifetime management (create/destroy instances) +- ✅ State isolation per-session via `sessionId` parameter +- ✅ Can instantiate multiple `AgentRuntime` (multi-project via `ProjectRegistry`) + +## Key Architectural Differences + +| Aspect | RPC Mode | Agent-Server | +|--------|----------|--------------| +| **Scope** | Function closure | Class instance | +| **Sessions** | ONE (mutable `let session`) | N concurrent (`Map`) | +| **Context creation** | `createExtensionUIContext()` - no params | `createExtensionUiContext(sessionId)` - scoped | +| **Request correlation** | Single Map (implicitly current session) | Map with `sessionId` field (explicit routing) | +| **Output** | `output()` → stdout | `publish(sessionId, ...)` → SSE broker | +| **Rebinding** | `rebindSession()` switches to new current | `bind(session)` adds to live set | +| **State lifetime** | Process lifetime (never returns) | Instance lifetime (can dispose) | + +## Is Agent-Server's Approach Correct? + +### ✅ Yes - Required by Multi-Session Model + +**RPC mode's pattern doesn't scale to concurrent sessions:** + +```typescript +// What if we tried RPC's pattern with N sessions? + +// PROBLEM 1: No way to know which session emitted the request +const pendingExtensionRequests = new Map(); // ← No sessionId! +// Extension A calls ui.select() while extension B also calls ui.select() +// Both get UUIDs, but nothing ties them back to sessions + +// PROBLEM 2: Output goes to single stdout +output({ type: "extension_ui_request", id: "uuid-1", ... }); +// Which HTTP client should receive this? All? One? How do we know? + +// PROBLEM 3: Response can't be routed +// Client responds to uuid-1, but we don't know which session's promise to resolve +``` + +**Agent-server's solution:** + +```typescript +// Explicit session routing +private createExtensionUiContext(sessionId: string): ExtensionUIContext { + return { + select: (...) => this.createDialogPromise(sessionId, ...) // ← Closure captures sessionId + }; +} + +// Request tagged with session +const pending = { + sessionId, // ← We know which session this belongs to + request, + resolve, +}; + +// Output routed to session's subscribers +publish(sessionId, request); // ← SSE broker fans out to that session's clients + +// Response validated against session +resolveExtensionUiRequest(sessionId, requestId, response) { + const pending = this.pendingExtensionUi.get(requestId); + if (pending.sessionId !== sessionId) return false; // ← Prevent hijacking + pending.resolve(response); +} +``` + +### ✅ Yes - Follows Pi's Layering Pattern + +From `docs/misc/edu/pi/pi-component-responsibilities.md`: + +> **RPC mode** is an adapter over `AgentSessionRuntime` (single-session lifecycle). +> **Agent-server** is an adapter over `AgentSession` (N concurrent sessions). + +Both implement `ExtensionUIContext`, but adapt it to their transport and concurrency model: + +| Mode | Transport | Concurrency | Context Creation | +|------|-----------|-------------|------------------| +| Interactive (TUI) | Terminal I/O | Single session | Function closure | +| RPC | stdin/stdout | Single session (switchable) | Function closure | +| Agent-server | HTTP+SSE | N concurrent | Class instance + session param | + +**All are valid implementations of the same interface, adapted to their environment.** + +### ✅ Yes - Class vs Closure is Style, Not Substance + +RPC mode could be refactored as a class: + +```typescript +class RpcMode { + private session: AgentSession; + private pendingExtensionRequests = new Map(); + + private createExtensionUIContext(): ExtensionUIContext { + return { + select: (...) => this.createDialogPromise(...) + }; + } + + async run(runtimeHost: AgentSessionRuntime) { ... } +} +``` + +Agent-server could be refactored as nested functions: + +```typescript +export function createAgentRuntime(config): AgentRuntime { + const live = new Map(); + const pendingExtensionUi = new Map(); + + const createExtensionUiContext = (sessionId) => ({ ... }); + + return { + createNewSession: async () => { ... }, + sendPrompt: async (id, text) => { ... }, + // ... + }; +} +``` + +**The real difference is multi-session vs single-session, not class vs closure.** + +## Recommendations + +### ✅ Keep Agent-Server's Current Implementation + +1. **Class-based state** is appropriate for multi-session lifecycle management +2. **Session-scoped context** (`createExtensionUiContext(sessionId)`) is necessary for routing +3. **Map-based tracking** (`pendingExtensionUi` with `sessionId` field) prevents cross-session contamination +4. **Publish-based output** (`publish(sessionId, event)`) correctly fans out to N SSE clients + +### 📖 Document the Difference + +Already done in `docs/architecture/pi-modes-analysis.md`: + +> **Fundamental Difference: Single vs Multi-Session** +> +> RPC uses `AgentSessionRuntime` for single-session-switching. +> Agent-server's concurrent multi-session model requires managing sessions differently (Map of live sessions). + +### 🎯 Pattern Consistency + +Both implementations follow the same **core pattern** from Pi: + +```typescript +// 1. Pending request correlation +const pending = new Map(); + +// 2. Dialog promise with timeout/abort +function createDialogPromise(opts, defaultValue, request, parseResponse) { + const id = randomUUID(); + return new Promise((resolve) => { + // Timeout handling + if (opts?.timeout) setTimeout(() => resolve(defaultValue), opts.timeout); + + // Abort signal handling + opts?.signal?.addEventListener("abort", () => resolve(defaultValue)); + + // Store resolver + pending.set(id, { resolve: (response) => resolve(parseResponse(response)) }); + + // Emit request + emitRequest({ id, ...request }); + }); +} + +// 3. Extension UI context as interface implementation +const createExtensionUIContext = () => ({ + select: (...) => createDialogPromise(...), + confirm: (...) => createDialogPromise(...), + notify: (...) => emitRequest(...), // Fire-and-forget + // ... +}); +``` + +Agent-server adds **one parameter** (`sessionId`) to route requests in a multi-session environment. That's the only architectural difference. + +## Conclusion + +**Agent-server's ExtensionUI implementation is architecturally sound.** + +It correctly adapts Pi's RPC mode pattern to a multi-session HTTP+SSE environment: + +- ✅ Uses same request/response correlation pattern +- ✅ Uses same timeout/abort handling +- ✅ Adds session routing where RPC mode has implicit current session +- ✅ Publishes to SSE where RPC mode writes to stdout + +**The choice of class vs closure is a stylistic consequence of the concurrency model, not a deviation from Pi's architecture.** + +RPC mode = single-session closure +Agent-server = multi-session class instance + +Both are valid implementations of `ExtensionUIContext` for their respective environments. diff --git a/docs/architecture/other/rpc-vs-custom-server.md b/docs/architecture/other/rpc-vs-custom-server.md new file mode 100644 index 0000000..7a953b5 --- /dev/null +++ b/docs/architecture/other/rpc-vs-custom-server.md @@ -0,0 +1,281 @@ +# RPC Mode vs Custom Server Architecture + +## Context + +Agent-server is built directly on Pi's `AgentSession` library rather than using Pi's built-in RPC mode. This document explains the architectural decision and trade-offs. + +## What is Pi's RPC Mode? + +Pi's RPC mode (`@earendil-works/pi-coding-agent/modes/rpc`) provides a headless JSON-RPC protocol over stdin/stdout: + +```typescript +import { RpcClient } from "@earendil-works/pi-coding-agent/modes/rpc"; + +// Spawns: pi --rpc +const client = new RpcClient({ cwd: "/project" }); +await client.start(); + +// Commands sent as JSON lines to stdin +await client.prompt("hello"); + +// Events emitted as JSON lines from stdout +client.onEvent((event) => console.log(event)); +``` + +**Design intent:** Embed a coding agent in desktop apps, IDEs, or non-Node.js environments where spawning a child process is natural. + +**Reference:** [`node_modules/@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts`](../../node_modules/@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts) + +## Why Agent-Server Uses Direct AgentSession + +### 1. Multi-Project Architecture + +**Requirement:** Serve multiple isolated projects in a single HTTP server, each with its own system prompt, skills, and session storage. + +**RPC limitation:** One RPC process = one runtime with one project directory: + +```typescript +// Pi RPC: Single project per process +const client = new RpcClient({ cwd: "/project-a" }); +// Can only switch between sessions within /project-a +await client.switchSession("other.jsonl"); +``` + +**Agent-server solution:** `ProjectRegistry` manages multiple in-process runtimes: + +```typescript +// src/projectRegistry.ts +export class ProjectRegistry { + private readonly runtimes = new Map(); + + forProject(context: ProjectRuntimeContext): AgentRuntime { + const projectDir = resolve(context.projectDir); + const existing = this.runtimes.get(context.id); + if (existing?.projectDir === projectDir) return existing.runtime; + + return this.createRuntime({ ...context, projectDir }); + } +} +``` + +Each runtime gets isolated: +- `projectDir`: Root for skill/extension discovery +- `sessionsDir`: `${projectDir}/.pi/sessions` +- `agentsFile`: `${projectDir}/.pi/AGENTS.md` (auto-loaded per Pi convention) +- Extensions: Project-local `.pi/extensions/` + +**With RPC, we would need:** +1. Spawn N `pi --rpc` child processes (one per project) +2. Build a router to map `projectId` → RPC client +3. Handle process lifecycle (spawn, crash recovery, cleanup) +4. Serialize access to each project's stdio pipe + +**Reference:** [`src/projectRegistry.ts`](../../src/projectRegistry.ts) + +### 2. Web-Native Protocol + +**Requirement:** Serve browser clients with standard HTTP REST + SSE streaming. + +**RPC protocol:** stdin/stdout JSON lines, designed for process embedding: + +```typescript +// Command (written to stdin) +{"type":"prompt","message":"hello","id":"req-123"} + +// Response (read from stdout) +{"type":"response","command":"prompt","success":true,"id":"req-123"} + +// Event (read from stdout) +{"type":"message_update","message":{...}} +``` + +**Agent-server protocol:** Native HTTP endpoints: + +```typescript +// src/routes.ts +POST /v1/projects/{id}/sessions/{sessionId}/prompt +GET /v1/projects/{id}/sessions/{sessionId}/events (SSE) +GET /v1/projects/{id}/sessions +PATCH /v1/projects/{id}/sessions/{sessionId}/settings +``` + +Browser consumption: +```typescript +// Standard fetch + EventSource +await fetch('/v1/projects/abc/sessions/123/prompt', { + method: 'POST', + body: JSON.stringify({ message: 'hello' }) +}); + +const events = new EventSource('/v1/projects/abc/sessions/123/events'); +events.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +**With RPC, we would need:** +1. HTTP server that writes to RPC stdin +2. Bridge stdout JSON lines → SSE data frames +3. Request correlation (HTTP request ID → stdin/stdout ID) +4. Handle protocol differences (HTTP timeouts, SSE keepalive, stdio buffering) + +**Reference:** [`src/routes.ts`](../../src/routes.ts), [`node_modules/@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts`](../../node_modules/@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts) + +### 3. Concurrent Multi-Client Support + +**Requirement:** Multiple browser clients (tabs, users) can watch the same session or different sessions concurrently. + +**RPC mode:** Single client owns the process, receives all events: + +```typescript +// RpcClient is 1:1 with the RPC process +const client = new RpcClient(); +client.onEvent((event) => { + // This callback receives ALL events for ALL sessions + // Filtering and routing is the client's responsibility +}); +``` + +**Agent-server solution:** SSE broker with pub/sub fan-out: + +```typescript +// src/sseBroker.ts +const channels = new Map>(); + +export function publish(sessionId: string, event: unknown): void { + const subs = channels.get(sessionId); + if (!subs) return; + for (const res of subs) { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } +} + +export function subscribe(sessionId: string, res: Response): void { + if (!channels.has(sessionId)) channels.set(sessionId, new Set()); + channels.get(sessionId)!.add(res); +} +``` + +Usage in appx (Go proxy): +```go +// appx/internal/server/agent_proxy.go +// Multiple browser tabs can stream same session +GET /appx/projects/{id}/agent/sessions/{sessionId}/events + +// All receive same AgentSessionEvent stream via SSE broker +``` + +**With RPC, we would need:** +1. Parse every stdout event to extract session ID +2. Maintain `Map>` +3. Fan out each event to N connections +4. Handle connection lifecycle (reconnect, cleanup) +5. Queue events during reconnection gaps + +Agent-session events published via `runtime.ts`: +```typescript +// src/runtime.ts +private bind(session: AgentSession): void { + const unsubscribe = session.subscribe((event: AgentSessionEvent) => { + publish(id, event); // SSE broker handles fan-out + }); +} +``` + +**Reference:** [`src/sseBroker.ts`](../../src/sseBroker.ts), [`src/runtime.ts`](../../src/runtime.ts) + +### 4. Deployment and Integration Simplicity + +**Requirement:** Single-process deployment with standard HTTP reverse proxy integration. + +**RPC approach would require:** + +``` +┌─────────────────────────────────────┐ +│ HTTP Server (Node.js) │ +│ ├─ Process Manager │ +│ │ ├─ spawn("pi", ["--rpc"]) │ +│ │ ├─ respawn on crash │ +│ │ └─ monitor N processes │ +│ ├─ Request Router │ +│ │ └─ projectId → RPC client │ +│ ├─ Protocol Bridge │ +│ │ ├─ HTTP → stdin JSON │ +│ │ ├─ stdout JSON → SSE │ +│ │ └─ correlation tracking │ +│ └─ Error Handling │ +│ ├─ stdio errors │ +│ ├─ process crashes │ +│ └─ buffer overflows │ +└─────────────────────────────────────┘ +``` + +**Agent-server approach:** + +``` +┌─────────────────────────────────────┐ +│ HTTP Server (Node.js) │ +│ ├─ ProjectRegistry │ +│ │ └─ Map │ +│ ├─ Direct method calls │ +│ │ └─ runtime.sendPrompt(id, text) │ +│ └─ Standard HTTP error handling │ +└─────────────────────────────────────┘ +``` + +From `projectRegistry.ts`: +```typescript +forProject(context: ProjectRuntimeContext): AgentRuntime { + const existing = this.runtimes.get(context.id); + if (existing?.projectDir === projectDir) return existing.runtime; + + // Just instantiate in-memory, no process spawning + return this.createRuntime(context); +} +``` + +**Operational advantages:** +- **Single process**: Standard systemd/Docker deployment +- **No IPC**: Direct method calls, no serialization overhead +- **Simpler debugging**: One process to attach, standard Node.js profiling +- **Standard monitoring**: Single PID, memory/CPU in one view +- **Graceful shutdown**: Just `server.close()`, no child process cleanup + +**Integration with appx:** +```go +// appx/internal/server/agent_proxy.go +// Standard HTTP reverse proxy to agent-server +proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = "http" + req.URL.Host = "localhost:8001" // agent-server + req.Header.Set("X-Appx-Project-Id", projectID) + }, +} +``` + +No special handling for child processes, stdio, or IPC. + +**Reference:** [`src/projectRegistry.ts`](../../src/projectRegistry.ts), [`appx/internal/server/agent_proxy.go`](https://github.com/neuromaxer/appx/blob/main/internal/server/agent_proxy.go) + +## When to Use RPC Mode + +Pi's RPC mode is excellent for: + +1. **Non-Node.js environments**: Python, Go, Rust clients that can spawn processes and parse JSON +2. **Process isolation**: Security boundaries where the agent must run sandboxed +3. **Desktop apps**: Embedding in Electron, VSCode extensions, CLI tools +4. **Single-project workflows**: Traditional IDE-style agent interactions + +## Conclusion + +Agent-server's architecture is optimized for its requirements: + +- ✅ **Multi-project**: N isolated runtimes in one process +- ✅ **Web-native**: HTTP+SSE without protocol bridging +- ✅ **Multi-client**: Native pub/sub event fan-out +- ✅ **Simple deployment**: Single Node.js process, standard reverse proxy + +Using RPC mode would add complexity (process management, IPC bridging, event routing) without providing benefits for a Node.js web server use case. + +**Trade-off:** We're coupled to Pi's Node.js SDK and running in the same process. If we needed language-agnostic clients or process isolation, RPC mode would be the right choice. + +**Industry alignment:** This follows the pattern of web frameworks that provide both library (Express, Fastify) and standalone server (nginx, Apache) modes. We're using the library mode because we're already in the same runtime. diff --git a/docs/superpowers/plans/2026-05-27-credentials-extraction.md b/docs/superpowers/plans/2026-05-27-credentials-extraction.md new file mode 100644 index 0000000..8d9ec26 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-credentials-extraction.md @@ -0,0 +1,1348 @@ +# Credentials Extraction + Thinking-Level Dedup Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract process-global auth/model/custom-provider/OAuth state from `AgentRuntime` into a new `AgentCredentialsService`, owned by `AgentRuntimeRegistry`. Delete duplicated thinking-level clamp/levels logic from `runtime.ts` and `litellm.ts`; replace with a single thin wrapper module backed by `@earendil-works/pi-ai`. + +**Architecture:** Today `AgentRuntime` mixes two lifetimes: process-global credential state (auth storage, model registry, OAuth flows, `models.json` CRUD) and per-project session state (live sessions, prompt/abort, settings). In multi mode the credential routes are mounted only against `defaultRuntime`, so those methods are dead code on N–1 of N runtime instances. We move credential code into a new `AgentCredentialsService` that the registry constructs once and that handles `/v1/auth/*` and `/v1/custom/*`. `AgentRuntime` keeps a reference to the service for read-only projections (e.g. `listModels`, `modelRow` used in session settings). Session creation routes still go through `AgentRuntime`. Separately, the duplicated thinking-level helpers move into a new `src/thinking.ts` that delegates to Pi's `getSupportedThinkingLevels` / `clampThinkingLevel` from `@earendil-works/pi-ai`. + +**Tech Stack:** TypeScript, Hono `@hono/zod-openapi`, Pi SDK (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`), Zod, Node test runner (`node --test` via `tsx`). + +--- + +## File Structure + +**New files:** +- `src/thinking.ts` — thin re-exports / wrappers around `@earendil-works/pi-ai`'s clamp + supported-levels helpers, plus the `THINKING_LEVELS` constant. One source of truth for the runtime + litellm. +- `src/credentialsService.ts` — `AgentCredentialsService` class. Owns `AuthStorage`, `ModelRegistry`, `models.json` CRUD, OAuth flow state machine, `listAuthProviders`, `listModels`, `modelRow`. Keeps the wire shape (`AgentAuthProviderRow`, `AgentCustomProviderRow`, `AgentOAuthFlowState`, `AgentModelRow`) verbatim so the OpenAPI contract is unchanged. +- `test/credentialsService.test.ts` — direct unit tests for the new class (no HTTP layer), exercising auth status merging, OAuth reuse, custom-provider CRUD, and `listModels` projection. + +**Modified files:** +- `package.json` — add `@earendil-works/pi-ai` as a direct dependency at the same minor as our pinned coding-agent. +- `src/runtime.ts` — remove auth, OAuth flow, custom-provider, listModels/listAuthProviders, modelRow, and clamp/supported-levels code. Accept the credentials service via constructor. Keep session methods, extension UI bridge, agentsFile loader. Replace internal clamp calls with imports from `./thinking.js`. +- `src/litellm.ts` — replace the duplicated `supportedThinkingLevels` / `clampThinkingLevel` / `THINKING_LEVELS` with imports from `./thinking.js`. +- `src/runtimeRegistry.ts` — construct `AgentCredentialsService` once, pass it down to every `AgentRuntime`. Stop wiring `AuthStorage`/`ModelRegistry` directly into runtimes. +- `src/routes.ts` — split: keep session routes in `createSessionsApp(runtime, options)` but make `credentialRoutes` accept either an `AgentRuntime` (back-compat) or an `AgentCredentialsService`. Cleanest split: introduce `createCredentialsApp(credentials)` and have `createSessionsApp` shed the credential routes entirely. Update callers. +- `src/server.ts` — call `createCredentialsApp(registry.credentials)` for `/v1` and `createSessionsApp(...)` for the session-shaped routes (in single mode mount on `/v1`; in multi mode mount on `/v1/projects/:projectId`). +- `src/openapi.ts` — mirror the new mounting structure so the published `openapi.json` matches the live server. +- `src/index.ts` — re-export `AgentCredentialsService`, `createCredentialsApp`, and the new thinking helpers. +- `test/server.test.ts` — adjust the embedded multi-mode test setup to mount `createCredentialsApp` separately, matching the new server.ts. + +--- + +## Task 1: Add pi-ai dependency + +**Files:** +- Modify: `package.json:25-32` + +- [ ] **Step 1: Inspect current dependency versions** + +Run: `cat package.json` +Expected output (relevant block): +```json +"dependencies": { + "@earendil-works/pi-coding-agent": "0.75.4", + ... +} +``` + +- [ ] **Step 2: Add pi-ai pinned to the same patch level** + +Edit `package.json` to add `"@earendil-works/pi-ai": "0.75.4"` to the `dependencies` block, alphabetically before `pi-coding-agent`: + +```json +"dependencies": { + "@earendil-works/pi-ai": "0.75.4", + "@earendil-works/pi-coding-agent": "0.75.4", + "@hono/node-server": "^1.13.7", + "@hono/swagger-ui": "^0.5.1", + "@hono/zod-openapi": "^0.19.2", + "hono": "^4.6.14", + "zod": "^3.24.1" +} +``` + +- [ ] **Step 3: Install** + +Run: `npm install` +Expected: package-lock.json updated, no errors. + +- [ ] **Step 4: Verify the import resolves** + +Run: `node -e "import('@earendil-works/pi-ai').then(m => console.log(typeof m.clampThinkingLevel, typeof m.getSupportedThinkingLevels))"` +Expected output: `function function` + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): add @earendil-works/pi-ai for shared thinking-level helpers" +``` + +--- + +## Task 2: Introduce src/thinking.ts as the single source of truth + +**Files:** +- Create: `src/thinking.ts` +- Test: `test/thinking.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `test/thinking.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { THINKING_LEVELS, clampThinkingLevelForModel, supportedThinkingLevelsForModel, type ThinkingLevel } from "../src/thinking.js"; + +const reasoningModel = { + reasoning: true as const, + thinkingLevelMap: { off: "none", low: "low", medium: "medium", high: "high" } as Record, +}; + +const nonReasoningModel = { + reasoning: false as const, + thinkingLevelMap: undefined, +}; + +describe("thinking helpers", () => { + test("THINKING_LEVELS includes off and xhigh in canonical order", () => { + assert.deepEqual(THINKING_LEVELS, ["off", "minimal", "low", "medium", "high", "xhigh"] satisfies ThinkingLevel[]); + }); + + test("non-reasoning models support only off", () => { + assert.deepEqual(supportedThinkingLevelsForModel(nonReasoningModel), ["off"]); + }); + + test("supported levels exclude null entries and require explicit xhigh", () => { + const supported = supportedThinkingLevelsForModel(reasoningModel); + assert.ok(supported.includes("low")); + assert.ok(supported.includes("high")); + assert.ok(!supported.includes("xhigh"), "xhigh requires an explicit map entry"); + }); + + test("clamp picks the next-higher level when requested level is unsupported", () => { + const minimalNullModel = { + reasoning: true as const, + thinkingLevelMap: { off: "none", minimal: null, low: "low", medium: "medium", high: "high" } as Record, + }; + assert.equal(clampThinkingLevelForModel(minimalNullModel, "minimal"), "low"); + }); + + test("clamp falls back to the lowest supported level when requested is too high", () => { + const onlyOff = { reasoning: false as const, thinkingLevelMap: undefined }; + assert.equal(clampThinkingLevelForModel(onlyOff, "high"), "off"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test test/thinking.test.ts` +Expected: FAIL with module not found `../src/thinking.js`. + +- [ ] **Step 3: Write src/thinking.ts** + +Create `src/thinking.ts` with this exact content: + +```ts +/** + * Thin wrapper over Pi's thinking-level helpers. + * + * Pi owns the canonical clamp + supported-levels logic in + * `@earendil-works/pi-ai/models.ts`. We re-export them under + * agent-server-friendly names and a `Pick`-style type so callers can + * pass either a real Pi `Model` or a partial { reasoning, thinkingLevelMap } + * shape (used by litellm config validation). + */ +import { + type Api, + clampThinkingLevel, + getSupportedThinkingLevels, + type Model, +} from "@earendil-works/pi-ai"; + +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; + +type ThinkingLevelInput = Pick, "reasoning" | "thinkingLevelMap">; + +export function supportedThinkingLevelsForModel(model: ThinkingLevelInput): ThinkingLevel[] { + return getSupportedThinkingLevels(model as Model) as ThinkingLevel[]; +} + +export function clampThinkingLevelForModel(model: ThinkingLevelInput, level: ThinkingLevel): ThinkingLevel { + return clampThinkingLevel(model as Model, level) as ThinkingLevel; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx tsx --test test/thinking.test.ts` +Expected: 5 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/thinking.ts test/thinking.test.ts +git commit -m "feat(thinking): add src/thinking.ts wrapping Pi's clamp helpers" +``` + +--- + +## Task 3: Migrate runtime.ts and litellm.ts to use src/thinking.ts + +**Files:** +- Modify: `src/runtime.ts:45-47, 368-396, 1180-1187` +- Modify: `src/litellm.ts:60, 72, 240-263, 302, 390, 393, 454` + +- [ ] **Step 1: Run baseline tests** + +Run: `npm test` +Expected: all tests pass (record count for next step). + +- [ ] **Step 2: Replace duplicated helpers in runtime.ts** + +In `src/runtime.ts`: + +a) Update the imports near the top — find the existing `export type ThinkingLevel = …` line (around line 45) and the `THINKING_LEVELS` const (line 47). Replace both, plus the local helpers `supportedThinkingLevelsForModel` (line 368) and `clampThinkingLevelForModel` (line 378), with imports. + +After: top of file additions/replacements: + +```ts +import { + THINKING_LEVELS, + type ThinkingLevel, + clampThinkingLevelForModel, + supportedThinkingLevelsForModel, +} from "./thinking.js"; +``` + +b) Delete the local `THINKING_LEVELS` constant (originally line 47). + +c) Delete `supportedThinkingLevelsForModel` (lines 368–376) and `clampThinkingLevelForModel` (lines 378–391) — they are now imported. + +d) Update both `private`-method call sites (`defaultThinkingForModel` at line 393 and `setSessionModelInternal` at line 1180) to call the imported free functions instead of `this.supportedThinkingLevelsForModel(...)` / `this.clampThinkingLevelForModel(...)`. Example: + +Before: +```ts +const nextAvailableLevels = this.supportedThinkingLevelsForModel(model); +``` +After: +```ts +const nextAvailableLevels = supportedThinkingLevelsForModel(model); +``` + +e) Re-export `ThinkingLevel` for back-compat: at the bottom of the existing exports near the top of the file, change `export type ThinkingLevel = NonNullable;` to `export type { ThinkingLevel } from "./thinking.js";`. (We keep the same surface so consumers don't have to update imports.) + +- [ ] **Step 3: Replace duplicated helpers in litellm.ts** + +In `src/litellm.ts`: + +a) Add the import at the top, after the existing imports: + +```ts +import { + THINKING_LEVELS as SHARED_THINKING_LEVELS, + clampThinkingLevelForModel, + supportedThinkingLevelsForModel, + type ThinkingLevel, +} from "./thinking.js"; +``` + +b) Delete the local `THINKING_LEVELS` const (line 72) and the `supportedThinkingLevels` (lines 240–248) and `clampThinkingLevel` (lines 250–263) functions. + +c) Replace **all** call sites of the deleted local helpers in this file: +- `THINKING_LEVELS.indexOf(level)` → `SHARED_THINKING_LEVELS.indexOf(level)` (lines around 253, 258 and elsewhere) +- `THINKING_LEVELS.filter(...)` → `SHARED_THINKING_LEVELS.filter(...)` +- Standalone calls `supportedThinkingLevels(entry)` → `supportedThinkingLevelsForModel(entry)` +- Standalone calls `clampThinkingLevel(model, level)` → `clampThinkingLevelForModel(model, level)` +- The previously-existing usage `THINKING_LEVELS.join(", ")` for error messages should also use `SHARED_THINKING_LEVELS.join(", ")`. + +d) Delete the local `import type { ... ThinkingLevel ... } from "./runtime.js";` if present (around line 9). Replace with the import in step (a). + +- [ ] **Step 4: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Run all tests** + +Run: `npm test` +Expected: same count as baseline, all green. Specifically the LiteLLM "applies preset compat" test should still pass — it asserts `compat?.supportsReasoningEffort === true`. + +- [ ] **Step 6: Commit** + +```bash +git add src/runtime.ts src/litellm.ts +git commit -m "refactor(thinking): replace duplicated clamp helpers with src/thinking.ts" +``` + +--- + +## Task 4: Scaffold AgentCredentialsService (constructor only) + +**Files:** +- Create: `src/credentialsService.ts` +- Test: `test/credentialsService.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `test/credentialsService.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { after, before, describe, test } from "node:test"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { AgentCredentialsService } from "../src/credentialsService.js"; + +function makeAgentDir(): { dir: string; cleanup: () => void } { + const dir = mkdtempSync(resolve(tmpdir(), "agent-server-creds-")); + mkdirSync(dir, { recursive: true }); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe("AgentCredentialsService", () => { + let agent: { dir: string; cleanup: () => void }; + + before(() => { + agent = makeAgentDir(); + }); + + after(() => { + agent.cleanup(); + }); + + test("constructor requires authStorage and modelRegistry references", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + assert.equal(typeof service.listAuthProviders, "function"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: FAIL with module not found `../src/credentialsService.js`. + +- [ ] **Step 3: Write the minimal credentialsService.ts** + +Create `src/credentialsService.ts`: + +```ts +/** + * AgentCredentialsService — process-global credential state. + * + * Owns AuthStorage, ModelRegistry, models.json CRUD, and the in-memory + * OAuth subscription flow state machine. AgentRuntime instances hold a + * reference for read-only projections (listModels, modelRow used in + * session settings). Routes for /v1/auth/* and /v1/custom/* call this + * directly via createCredentialsApp. + */ +import type { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; + +export type AgentCredentialsServiceConfig = { + authStorage: AuthStorage; + modelRegistry: ModelRegistry; + modelsJsonPath: string; + logger?: Pick; +}; + +export class AgentCredentialsService { + private readonly authStorage: AuthStorage; + private readonly modelRegistry: ModelRegistry; + private readonly modelsJsonPath: string; + private readonly logger: Pick; + + constructor(config: AgentCredentialsServiceConfig) { + this.authStorage = config.authStorage; + this.modelRegistry = config.modelRegistry; + this.modelsJsonPath = config.modelsJsonPath; + this.logger = config.logger ?? console; + } + + listAuthProviders(): never { + throw new Error("not yet implemented"); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: 1 passing test ("constructor requires authStorage and modelRegistry references"). + +- [ ] **Step 5: Commit** + +```bash +git add src/credentialsService.ts test/credentialsService.test.ts +git commit -m "feat(credentials): scaffold AgentCredentialsService class" +``` + +--- + +## Task 5: Move listModels and modelRow into the credentials service + +**Files:** +- Modify: `src/credentialsService.ts` +- Modify: `test/credentialsService.test.ts` + +- [ ] **Step 1: Write a failing test** + +Append to `test/credentialsService.test.ts` inside the `describe` block: + +```ts +test("listModels returns Pi-shaped rows with availability flag", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + authStorage.set("anthropic", { type: "api_key", key: "sk-ant-test" }); + modelRegistry.refresh(); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + + const models = service.listModels(); + const anthropic = models.find((m) => m.provider === "anthropic"); + assert.ok(anthropic, "expected at least one anthropic model"); + assert.equal(anthropic!.available, true); + assert.equal(typeof anthropic!.contextWindow, "number"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: FAIL with `service.listModels is not a function`. + +- [ ] **Step 3: Move types and methods from runtime.ts into credentialsService.ts** + +In `src/credentialsService.ts`: + +a) Add imports + helper types at the top: + +```ts +import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent"; +import { + type ThinkingLevel, + clampThinkingLevelForModel, +} from "./thinking.js"; + +type SessionModel = NonNullable; + +export type AgentModelRow = { + provider: string; + id: string; + name: string; + api: string; + reasoning: boolean; + available: boolean; + input: Array<"text" | "image">; + contextWindow: number; + maxTokens: number; + defaultThinkingLevel?: ThinkingLevel; +}; +``` + +b) Extend `AgentCredentialsServiceConfig` to accept the optional thinking defaults that were previously on `AgentRuntimeConfig` (these are needed by the credentials-side `modelRow` projection): + +```ts +export type AgentCredentialsServiceConfig = { + authStorage: AuthStorage; + modelRegistry: ModelRegistry; + modelsJsonPath: string; + defaultModelProvider?: string; + defaultModelId?: string; + defaultThinkingLevel?: ThinkingLevel; + modelThinkingDefaults?: Record; + logger?: Pick; +}; +``` + +c) Store them as private fields in the constructor body (mirror the existing assignment pattern). + +d) Add the methods. Replace the placeholder `listAuthProviders` with this body of methods: + +```ts +private modelKey(model: Pick): string { + return `${model.provider}/${model.id}`; +} + +defaultThinkingForModel(model: SessionModel): ThinkingLevel | undefined { + const configured = this.modelThinkingDefaults[this.modelKey(model)] ?? this.defaultThinkingLevel; + return configured ? clampThinkingLevelForModel(model, configured) : undefined; +} + +modelRow(model: SessionModel): AgentModelRow { + return { + provider: model.provider, + id: model.id, + name: model.name, + api: model.api, + reasoning: model.reasoning, + available: this.modelRegistry.hasConfiguredAuth(model), + input: [...model.input], + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + defaultThinkingLevel: this.defaultThinkingForModel(model), + }; +} + +listModels(): AgentModelRow[] { + return this.modelRegistry + .getAll() + .map((model) => this.modelRow(model as SessionModel)) + .sort( + (a, b) => + Number(b.available) - Number(a.available) || + a.provider.localeCompare(b.provider) || + a.name.localeCompare(b.name), + ); +} +``` + +(Keep `listAuthProviders` as a stub `throw new Error("not yet implemented")` — Task 6 fills it in.) + +e) Initialise the new fields in the constructor: + +```ts +private readonly defaultModelProvider: string | undefined; +private readonly defaultModelId: string | undefined; +private readonly defaultThinkingLevel: ThinkingLevel | undefined; +private readonly modelThinkingDefaults: Record; + +constructor(config: AgentCredentialsServiceConfig) { + this.authStorage = config.authStorage; + this.modelRegistry = config.modelRegistry; + this.modelsJsonPath = config.modelsJsonPath; + this.logger = config.logger ?? console; + this.defaultModelProvider = config.defaultModelProvider; + this.defaultModelId = config.defaultModelId; + this.defaultThinkingLevel = config.defaultThinkingLevel; + this.modelThinkingDefaults = config.modelThinkingDefaults ?? {}; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: 2 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/credentialsService.ts test/credentialsService.test.ts +git commit -m "feat(credentials): add listModels + modelRow projection" +``` + +--- + +## Task 6: Move listAuthProviders, setProviderApiKey, removeProviderCredential + +**Files:** +- Modify: `src/credentialsService.ts` +- Modify: `test/credentialsService.test.ts` + +- [ ] **Step 1: Write a failing test** + +Append to the `describe` block in `test/credentialsService.test.ts`: + +```ts +test("setProviderApiKey persists, listAuthProviders shows configured, removeProviderCredential clears", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + + service.setProviderApiKey("anthropic", "sk-ant-test"); + let providers = service.listAuthProviders(); + let anthropic = providers.find((p) => p.provider === "anthropic"); + assert.equal(anthropic?.configured, true); + assert.equal(anthropic?.source, "stored"); + + service.removeProviderCredential("anthropic"); + providers = service.listAuthProviders(); + anthropic = providers.find((p) => p.provider === "anthropic"); + // remaining anthropic row reflects no stored credential + assert.notEqual(anthropic?.source, "stored"); +}); + +test("setProviderApiKey rejects malformed provider id", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + assert.throws(() => service.setProviderApiKey("bad provider!", "k"), /invalid provider id/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: FAIL with `service.setProviderApiKey is not a function`. + +- [ ] **Step 3: Move methods from runtime.ts to credentialsService.ts** + +In `src/credentialsService.ts`, add the new types and methods: + +```ts +export type AgentAuthProviderRow = { + provider: string; + name: string; + configured: boolean; + credentialType?: "api_key" | "oauth"; + source?: "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command"; + label?: string; + supportsApiKey: boolean; + supportsSubscription: boolean; + modelCount: number; + availableModelCount: number; +}; + +private assertProviderId(provider: string): void { + if (!/^[a-zA-Z0-9_.:-]+$/.test(provider)) { + throw new Error("invalid provider id"); + } +} + +listAuthProviders(): AgentAuthProviderRow[] { + const byProvider = new Map(); + for (const model of this.listModels()) { + const current = byProvider.get(model.provider) ?? { modelCount: 0, availableModelCount: 0 }; + current.modelCount += 1; + if (model.available) current.availableModelCount += 1; + byProvider.set(model.provider, current); + } + const oauthProviderIds = new Set(this.authStorage.getOAuthProviders().map((provider) => provider.id)); + for (const provider of oauthProviderIds) { + if (!byProvider.has(provider)) { + byProvider.set(provider, { modelCount: 0, availableModelCount: 0 }); + } + } + return [...byProvider.entries()] + .map(([provider, counts]) => { + const status = this.modelRegistry.getProviderAuthStatus(provider); + const credential = this.authStorage.get(provider); + return { + provider, + name: this.modelRegistry.getProviderDisplayName(provider), + configured: status.configured || status.source !== undefined, + credentialType: credential?.type, + source: status.source, + label: status.label, + supportsApiKey: counts.modelCount > 0, + supportsSubscription: oauthProviderIds.has(provider), + ...counts, + }; + }) + .sort( + (a, b) => + Number(b.configured) - Number(a.configured) || + b.availableModelCount - a.availableModelCount || + a.provider.localeCompare(b.provider), + ); +} + +setProviderApiKey(provider: string, key: string): void { + this.assertProviderId(provider); + const trimmed = key.trim(); + if (!trimmed) throw new Error("key is required"); + this.authStorage.set(provider, { type: "api_key", key: trimmed }); + this.modelRegistry.refresh(); +} + +removeProviderCredential(provider: string): void { + this.assertProviderId(provider); + this.authStorage.remove(provider); + this.modelRegistry.refresh(); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: 4 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/credentialsService.ts test/credentialsService.test.ts +git commit -m "feat(credentials): move listAuthProviders + provider key CRUD" +``` + +--- + +## Task 7: Move OAuth subscription flow state machine + +**Files:** +- Modify: `src/credentialsService.ts` +- Modify: `test/credentialsService.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to the `describe` block in `test/credentialsService.test.ts`: + +```ts +test("startProviderSubscriptionLogin reuses an active flow", async () => { + let loginCalls = 0; + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + modelRegistry.registerProvider("test-reuse", { + name: "Test Reuse", + baseUrl: "https://example.test/v1", + api: "openai-completions", + oauth: { + name: "Test Reuse", + login: async (callbacks: any) => { + loginCalls += 1; + callbacks.onAuth?.({ url: "https://login.example.test/", instructions: "x" }); + await callbacks.onManualCodeInput?.(); + return { access: "tok", refresh: "rfr", expires: Date.now() + 60_000 }; + }, + refreshToken: async (c: any) => c, + getApiKey: (c: any) => c.access, + }, + models: [ + { id: "m", name: "M", api: "openai-completions", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 4096, maxTokens: 1024 }, + ], + }); + + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + + const first = await service.startProviderSubscriptionLogin("test-reuse"); + const second = await service.startProviderSubscriptionLogin("test-reuse"); + assert.equal(second.id, first.id); + assert.equal(loginCalls, 1); + + const cancelled = service.cancelProviderSubscriptionLogin(first.id); + assert.equal(cancelled?.status, "cancelled"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: FAIL with `service.startProviderSubscriptionLogin is not a function`. + +- [ ] **Step 3: Move OAuth flow code from runtime.ts to credentialsService.ts** + +a) Add the OAuth-related types (these are *unchanged* from `runtime.ts`): + +```ts +export type AgentAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type AgentOAuthFlowState = { + id: string; + provider: string; + providerName: string; + status: "starting" | "prompt" | "auth" | "waiting" | "complete" | "error" | "cancelled"; + authUrl?: string; + instructions?: string; + prompt?: AgentAuthPrompt; + progress: string[]; + error?: string; + expiresAt: string; +}; + +type PendingOAuthFlow = AgentOAuthFlowState & { + version: number; + abortController: AbortController; + promptResolve?: (value: string) => void; + promptReject?: (error: Error) => void; + manualResolve?: (value: string) => void; + manualReject?: (error: Error) => void; + waiters: Array<(state: AgentOAuthFlowState) => void>; + cleanupTimer?: ReturnType; +}; +``` + +b) Add `import { randomUUID } from "node:crypto";` to the file imports. + +c) Add the private map field: + +```ts +private readonly pendingOAuthFlows = new Map(); +``` + +d) Move these methods verbatim from `src/runtime.ts:869–1062` (with `private` access kept where they were private): +- `oauthFlowState` +- `updateOAuthFlow` +- `scheduleOAuthFlowCleanup` +- `activeOAuthFlowForProvider` +- `oauthLoginErrorMessage` +- `waitForOAuthFlowUpdate` +- `startProviderSubscriptionLogin` +- `continueProviderSubscriptionLogin` +- `getProviderSubscriptionLogin` +- `cancelProviderSubscriptionLogin` + +These bodies are unchanged. Public methods stay public; helpers stay private. (The plan requires the engineer to literally cut from one file and paste; do not edit logic.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: 5 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/credentialsService.ts test/credentialsService.test.ts +git commit -m "feat(credentials): move OAuth subscription flow state machine" +``` + +--- + +## Task 8: Move custom-provider models.json CRUD + +**Files:** +- Modify: `src/credentialsService.ts` +- Modify: `test/credentialsService.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to the `describe` block in `test/credentialsService.test.ts`: + +```ts +test("upsertCustomProvider writes models.json with 0600 perms and registers in ModelRegistry", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + + const row = service.upsertCustomProvider({ + provider: "litellm-test", + name: "LiteLLM Test", + baseUrl: "http://litellm.test/v1", + api: "openai-completions", + apiKey: "test-secret", + models: [ + { id: "test-model", name: "Test", api: "openai-completions", reasoning: false, input: ["text"], contextWindow: 4096, maxTokens: 1024 }, + ], + }); + assert.equal(row.provider, "litellm-test"); + assert.equal(row.apiKeyConfigured, true); + assert.equal(row.modelCount, 1); + + const listed = service.listCustomProviders(); + assert.ok(listed.some((p) => p.provider === "litellm-test")); + + service.removeCustomProvider("litellm-test"); + assert.equal(service.listCustomProviders().some((p) => p.provider === "litellm-test"), false); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: FAIL with `service.upsertCustomProvider is not a function`. + +- [ ] **Step 3: Move custom-provider code into credentialsService.ts** + +a) Add the types (unchanged from `runtime.ts`): + +```ts +const CUSTOM_PROVIDER_APIS = ["openai-completions", "openai-responses", "anthropic-messages"] as const; +export type AgentCustomProviderApi = (typeof CUSTOM_PROVIDER_APIS)[number]; + +export type AgentCustomProviderModel = { + id: string; + name?: string; + api?: AgentCustomProviderApi; + reasoning?: boolean; + thinkingLevelMap?: Partial>; + input?: Array<"text" | "image">; + contextWindow?: number; + maxTokens?: number; + compat?: Record; +}; + +export type AgentCustomProviderRow = { + provider: string; + name?: string; + baseUrl?: string; + api?: AgentCustomProviderApi; + apiKeyConfigured: boolean; + modelCount: number; + models: AgentCustomProviderModel[]; +}; + +export type UpsertCustomProviderRequest = { + provider: string; + name?: string; + baseUrl: string; + api: AgentCustomProviderApi; + apiKey?: string; + models: AgentCustomProviderModel[]; +}; +``` + +b) Add `chmodSync, existsSync, readFileSync, writeFileSync` to the existing `node:fs` import (currently has none — add the import). + +c) Move these methods *verbatim* from `runtime.ts:1064–1170`: +- `customProviderApi` (private) +- `readModelsJson` (private) +- `writeModelsJson` (private) +- `listCustomProviders` (public) +- `upsertCustomProvider` (public) +- `removeCustomProvider` (public) + +The bodies are unchanged. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx tsx --test test/credentialsService.test.ts` +Expected: 6 passing tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/credentialsService.ts test/credentialsService.test.ts +git commit -m "feat(credentials): move custom-provider models.json CRUD" +``` + +--- + +## Task 9: Update AgentRuntimeRegistry to construct + share AgentCredentialsService + +**Files:** +- Modify: `src/runtimeRegistry.ts:1-121` +- Modify: `src/runtime.ts` (constructor signature) + +- [ ] **Step 1: Run baseline** + +Run: `npm test` +Expected: passes (record any deltas). + +- [ ] **Step 2: Add credentials field to AgentRuntimeRegistry** + +In `src/runtimeRegistry.ts`: + +a) Update imports: + +```ts +import { AgentCredentialsService } from "./credentialsService.js"; +``` + +b) Add a public field `readonly credentials: AgentCredentialsService;` next to `readonly defaultRuntime: AgentRuntime;`. + +c) After constructing `this.modelRegistry` in the constructor, add: + +```ts +this.credentials = new AgentCredentialsService({ + authStorage: this.authStorage, + modelRegistry: this.modelRegistry, + modelsJsonPath: agentDir + ? join(agentDir, "models.json") + : join(this.config.projectDir, "models.json"), + defaultModelProvider: this.config.defaultModelProvider, + defaultModelId: this.config.defaultModelId, + defaultThinkingLevel: this.config.defaultThinkingLevel, + modelThinkingDefaults: this.config.modelThinkingDefaults, + logger: this.config.logger, +}); +``` + +(`join` from `node:path` is already imported.) + +d) Pass the service into every `AgentRuntime` via the existing `createRuntime` factory. In `createRuntime`, add `credentials: this.credentials,` to the `new AgentRuntime({ … })` call. + +- [ ] **Step 3: Update AgentRuntime constructor** + +In `src/runtime.ts`: + +a) Extend `AgentRuntimeConfig` with a required field: + +```ts +/** Process-global credentials service shared with sibling runtimes. */ +credentials: AgentCredentialsService; +``` + +b) Add the import: `import { AgentCredentialsService } from "./credentialsService.js";` + +c) Store in a private field: `private readonly credentials: AgentCredentialsService;`. Assign in constructor. + +d) **Do not yet remove** the in-runtime credential code in this task. We'll do that in Task 10 once the routes also point at the service. + +- [ ] **Step 4: Run TypeScript check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Run all tests** + +Run: `npm test` +Expected: same baseline pass count. + +- [ ] **Step 6: Commit** + +```bash +git add src/runtimeRegistry.ts src/runtime.ts +git commit -m "feat(registry): construct shared AgentCredentialsService and inject into runtimes" +``` + +--- + +## Task 10: Add createCredentialsApp; route credentials through it; deprecate credentialRoutes flag + +**Files:** +- Modify: `src/routes.ts` +- Modify: `src/schemas.ts` (no change expected — re-confirm) +- Modify: `src/server.ts` +- Modify: `src/openapi.ts` +- Modify: `test/server.test.ts` + +- [ ] **Step 1: Add createCredentialsApp factory** + +In `src/routes.ts`, add a new export *after* `createSessionsApp`: + +```ts +export type AgentCredentialsResolver = (c: Context) => AgentCredentialsService | Promise; + +export type CreateCredentialsAppOptions = { + healthRoute?: boolean; +}; + +export function createCredentialsApp( + credentials: AgentCredentialsService | AgentCredentialsResolver, + options: CreateCredentialsAppOptions = {}, +): OpenAPIHono { + const app = new OpenAPIHono(); + const healthRoute = options.healthRoute ?? true; + const getCredentials = (c: Context) => + typeof credentials === "function" ? credentials(c) : credentials; + + // Move every existing /auth/* and /custom/* route here, replacing + // `runtime.foo(...)` calls with `(await getCredentials(c)).foo(...)`. + // Move the GET /sessions/models route here too — it returns shared models. + // Move GET /healthz here when healthRoute=true. + + // ... full route bodies copied 1:1 from createSessionsApp ... + + return app; +} +``` + +a) Move every credential route from `createSessionsApp` (`routes.ts:165–467` plus `/healthz` and `/sessions/models`) into `createCredentialsApp`. Adjust handlers from `runtime.listAuthProviders()` to `(await getCredentials(c)).listAuthProviders()`, etc. + +b) **Important:** `/sessions/models` belongs to credentials (it's a projection of the shared registry), so move it too. The path stays `/sessions/models` for back-compat in single mode. In multi mode it remains under `/v1` (mounted via `createCredentialsApp`). + +c) Delete the moved routes from `createSessionsApp`. Remove the `credentialRoutes` and `healthRoute` flags from `CreateSessionsAppOptions` (now session-only). Keep `sessionRoutes` *only if* `createSessionsApp` is still used for cases where session routes need to be off — otherwise remove it entirely. (For cleanup: in this codebase `sessionRoutes: false` was only used to suppress the credential routes that are now in a different app, so it's safe to remove.) + +d) Update `createSessionsApp` signature to no longer take options: + +```ts +export function createSessionsApp(runtime: AgentRuntime | AgentRuntimeResolver): OpenAPIHono { + const app = new OpenAPIHono(); + // ... (existing session routes only) + return app; +} +``` + +- [ ] **Step 2: Update server.ts to mount credentials and sessions independently** + +In `src/server.ts`: + +a) Import `createCredentialsApp` from `./routes.js`. + +b) Replace the `if (mode === "single") { ... } else { ... }` block (lines 179–190) with: + +```ts +root.route("/v1", createCredentialsApp(runtimeRegistry.credentials)); +if (mode === "single") { + root.route("/v1", createSessionsApp(runtimeRegistry.defaultRuntime)); +} else { + root.route("/v1/projects/:projectId", createSessionsApp(projectRuntimeFromRequest)); +} +``` + +- [ ] **Step 3: Update openapi.ts to mirror server.ts** + +In `src/openapi.ts`, replace the mounting block (lines 33–44) with the same structure as `server.ts`. The stub uses a fresh `AgentRuntimeRegistry` to obtain `credentials`: + +```ts +import { AgentRuntimeRegistry } from "./runtimeRegistry.js"; + +const stubProjectDir = resolve(process.cwd()); +const registry = new AgentRuntimeRegistry({ + projectDir: stubProjectDir, + sessionsDir: resolve(stubProjectDir, ".tmp-openapi-sessions"), + defaultAgentsFile: false, + logger: { log: () => {}, error: () => {} }, +}); + +const root = new OpenAPIHono(); +root.route("/v1", createCredentialsApp(registry.credentials)); +if (mode === "single") { + root.route("/v1", createSessionsApp(registry.defaultRuntime)); +} else { + root.route("/v1/projects/:projectId", createSessionsApp(registry.defaultRuntime)); +} +``` + +- [ ] **Step 4: Update server.test.ts multi-mode test setup** + +In `test/server.test.ts`, update the two project-scoped describe-block tests so they mount the new app structure. The single-mode `startServer` helper changes: + +```ts +const root = new OpenAPIHono(); +if (opts.token) { + root.use("/v1/*", async (c, next) => { + const auth = c.req.header("authorization") ?? ""; + const presented = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + if (presented !== opts.token) return c.json({ error: "unauthorized" }, 401); + await next(); + }); +} +const registry = new AgentRuntimeRegistry({ + projectDir: opts.projectDir, + sessionsDir: resolve(opts.projectDir, "data/sessions"), + agentDir: resolve(opts.projectDir, ".pi-agent"), + agentsFile: ".pi/AGENTS.md", + logger: { log: () => {}, error: () => {} }, + ...(opts.runtimeConfig ?? {}), +}); +root.route("/v1", createCredentialsApp(registry.credentials)); +root.route("/v1", createSessionsApp(registry.defaultRuntime)); +``` + +(Drop the direct `new AgentRuntime` construction — the registry covers it. The optional `runtimeConfig` field still flows through if present, since `AgentRuntimeRegistryConfig` extends `AgentRuntimeConfig`.) + +The "project-scoped runtimes" describe block updates similarly: replace any explicit `{ sessionRoutes: false }` / `{ credentialRoutes: false }` toggles with the new mount structure. + +- [ ] **Step 5: Run tests to verify routes still answer correctly** + +Run: `npm test` +Expected: existing assertions in `server.test.ts` continue to pass — `GET /v1/auth/providers`, `PUT /v1/auth/providers/anthropic/api-key`, etc., all still work because we only changed where the routes are *mounted from*, not the URL paths. + +- [ ] **Step 6: Commit** + +```bash +git add src/routes.ts src/server.ts src/openapi.ts test/server.test.ts +git commit -m "refactor(routes): split credentials routes into createCredentialsApp" +``` + +--- + +## Task 11: Delete duplicated credential code from AgentRuntime; route session settings through credentials.modelRow + +**Files:** +- Modify: `src/runtime.ts` + +- [ ] **Step 1: Delete moved code from runtime.ts** + +In `src/runtime.ts`, delete the now-redundant code: + +a) Types: delete `AgentModelRow`, `AgentAuthProviderRow`, `AgentAuthPrompt`, `AgentOAuthFlowState`, `AgentCustomProviderApi`, `AgentCustomProviderModel`, `AgentCustomProviderRow`, `UpsertCustomProviderRequest`, the `CUSTOM_PROVIDER_APIS` constant, and the `PendingOAuthFlow` type. Re-export them from `./credentialsService.js` at the bottom of the file for back-compat: + +```ts +export type { + AgentAuthPrompt, + AgentAuthProviderRow, + AgentCustomProviderApi, + AgentCustomProviderModel, + AgentCustomProviderRow, + AgentModelRow, + AgentOAuthFlowState, + UpsertCustomProviderRequest, +} from "./credentialsService.js"; +``` + +b) Methods: delete `modelKey` (private), `defaultThinkingForModel` (private), `modelRow` (private), `listModels`, `listAuthProviders`, `setProviderApiKey`, `removeProviderCredential`, `assertProviderId`, `customProviderApi`, `oauthFlowState`, `updateOAuthFlow`, `scheduleOAuthFlowCleanup`, `activeOAuthFlowForProvider`, `oauthLoginErrorMessage`, `waitForOAuthFlowUpdate`, `startProviderSubscriptionLogin`, `continueProviderSubscriptionLogin`, `getProviderSubscriptionLogin`, `cancelProviderSubscriptionLogin`, `readModelsJson`, `writeModelsJson`, `listCustomProviders`, `upsertCustomProvider`, `removeCustomProvider`. Also delete the `pendingOAuthFlows` field. + +c) Update `sessionModelSettings` (around line 414) to delegate to credentials: + +```ts +private sessionModelSettings(session: AgentSession): SessionModelSettings { + return { + model: session.model ? this.credentials.modelRow(session.model as SessionModel) : null, + thinkingLevel: session.thinkingLevel as ThinkingLevel, + availableThinkingLevels: session.getAvailableThinkingLevels() as ThinkingLevel[], + supportsThinking: session.supportsThinking(), + isStreaming: session.isStreaming, + }; +} +``` + +d) Update `sessionModelDefaults` to use `this.credentials.defaultThinkingForModel(model)` instead of the local helper. + +e) Remove unused imports: `chmodSync`, `existsSync`, `readFileSync`, `writeFileSync`, `randomUUID` (verify with the lint step that they're truly unused). + +- [ ] **Step 2: TypeScript compile** + +Run: `npx tsc --noEmit` +Expected: no errors. If there are unused-import warnings, remove them. + +- [ ] **Step 3: Run all tests** + +Run: `npm test` +Expected: all green. + +- [ ] **Step 4: Commit** + +```bash +git add src/runtime.ts +git commit -m "refactor(runtime): drop credential code now provided by AgentCredentialsService" +``` + +--- + +## Task 12: Update src/index.ts public exports + +**Files:** +- Modify: `src/index.ts` + +- [ ] **Step 1: Update re-exports** + +In `src/index.ts`: + +a) Add credentials service exports: + +```ts +export { AgentCredentialsService } from "./credentialsService.js"; +export type { + AgentCredentialsServiceConfig, +} from "./credentialsService.js"; +export { createCredentialsApp } from "./routes.js"; +export type { AgentCredentialsResolver, CreateCredentialsAppOptions } from "./routes.js"; +``` + +b) Add thinking helper exports: + +```ts +export { THINKING_LEVELS, clampThinkingLevelForModel, supportedThinkingLevelsForModel } from "./thinking.js"; +``` + +c) The runtime type re-exports remain valid because `runtime.ts` re-exports them from `credentialsService.ts` (Task 11 step 1a). + +- [ ] **Step 2: TypeScript compile** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Run all tests** + +Run: `npm test` +Expected: all green. + +- [ ] **Step 4: Regenerate openapi.json and confirm it matches expected** + +Run: `npm run openapi` +Expected: `openapi.json` rewritten. Eyeball that: +- `/v1/auth/providers`, `/v1/sessions/models`, `/v1/healthz` are still present (single mode default). +- `/v1/sessions` and `/v1/sessions/{id}/...` still present. +- `/v1/projects/...` paths only when `AGENT_SERVER_MODE=multi` is set. + +Run: `git diff openapi.json` +Expected: ideally empty diff. If there are differences, they should be limited to: routes that moved between `tags` (e.g., the `models` tag now living under credentials) — *not* path changes. If a path is missing, that's a bug to fix in routes.ts. + +- [ ] **Step 5: Commit** + +```bash +git add src/index.ts openapi.json +git commit -m "chore(exports): re-export credentials service and thinking helpers" +``` + +--- + +## Task 13: Sweep dead code, run full smoke + +**Files:** +- Sweep: `src/` + +- [ ] **Step 1: Confirm there are no unused exports/types** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 2: Run full test suite** + +Run: `npm test` +Expected: all suites green; `agent-server: REST surface`, `agent-server: project-scoped runtimes`, `agent-server: bearer auth seam`, `agent-server: SSE`, `agent-server: LiteLLM config`, plus the new `AgentCredentialsService` and `thinking helpers` blocks all pass. + +- [ ] **Step 3: Manual smoke — start the server in single mode** + +Run (in a separate terminal): `PROJECT_DIR=$(pwd) npm run dev` +Then in this terminal: +```bash +curl -s http://127.0.0.1:4001/v1/healthz | head -c 200 +curl -s http://127.0.0.1:4001/v1/auth/providers | head -c 400 +curl -s http://127.0.0.1:4001/v1/sessions/models | head -c 400 +curl -s -X POST http://127.0.0.1:4001/v1/sessions | head -c 200 +``` +Expected: `200` for each, no 5xx, JSON shapes match the OpenAPI doc. + +- [ ] **Step 4: Manual smoke — start the server in multi mode** + +Stop the previous dev server, then: +```bash +AGENT_SERVER_MODE=multi PROJECT_DIR=$(pwd) npm run dev +``` +In this terminal: +```bash +curl -s http://127.0.0.1:4001/v1/healthz +curl -s http://127.0.0.1:4001/v1/auth/providers +curl -s http://127.0.0.1:4001/v1/sessions # expect 404 — sessions not mounted +curl -s -X POST -H "X-Appx-Project-Dir: $(pwd)" http://127.0.0.1:4001/v1/projects/test/sessions +curl -s -H "X-Appx-Project-Dir: $(pwd)" http://127.0.0.1:4001/v1/projects/test/sessions +``` +Expected: credentials respond on `/v1`; bare `/v1/sessions` returns 404; project-scoped `/v1/projects/test/sessions` works. + +Stop the dev server. + +- [ ] **Step 5: Commit (if anything moved during the sweep)** + +```bash +# only if files changed during sweep +git status +git add -p +git commit -m "chore: post-refactor cleanup" +``` + +If the working tree is clean, skip the commit. + +--- + +## Self-Review Notes + +- **Spec coverage:** Every item from the discussed scope is mapped to a task — pi-ai dep (1), thinking dedup (2–3), credentials scaffold (4), listModels/modelRow (5), auth-providers + key CRUD (6), OAuth flow (7), custom providers (8), registry wiring (9), route split (10), runtime cleanup (11), exports (12), sweep (13). The OpenAPI surface is preserved by keeping all paths under the same URL prefixes. +- **Placeholder scan:** No "TBD" / "implement later" instructions; every "move method X" step lists the source line range to copy from. +- **Type consistency:** `AgentModelRow`, `AgentAuthProviderRow`, `AgentOAuthFlowState`, etc. retain their wire shapes; the only new types are `AgentCredentialsServiceConfig`, `AgentCredentialsResolver`, `CreateCredentialsAppOptions`. Pi types (`Model`, `Api`, `AuthStorage`, `ModelRegistry`) come straight from `@earendil-works/pi-coding-agent` / `@earendil-works/pi-ai`. +- **Risk callouts:** + - Task 7 ("move OAuth flow code verbatim") is the largest cut/paste step. The bodies are unchanged — verify by running the existing OAuth tests in `server.test.ts` plus the new reuse test in `credentialsService.test.ts`. + - Task 10 (route split) changes which app handles each path but keeps URLs. The `npm run openapi` step in Task 12 is the smoke check that the contract didn't drift. + - Task 11 deletes ~700 lines from `runtime.ts`. If anything was missed, TypeScript will scream because the deleted method names are no longer reachable. Trust `tsc --noEmit`. diff --git a/docs/superpowers/plans/2026-06-02-pi-conventions-alignment.md b/docs/superpowers/plans/2026-06-02-pi-conventions-alignment.md new file mode 100644 index 0000000..fef4ddb --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-pi-conventions-alignment.md @@ -0,0 +1,163 @@ +# Plan: Align agent-server with Pi's project conventions + +**Date:** 2026-06-02 +**Status:** Drafted + +## Goal + +Eliminate the special-casing around the "default" runtime by adopting Pi's +two-tier filesystem convention uniformly across every `ProjectRuntime` +(default and per-project). This: + +1. Removes the `defaultAgentsFile` flag and the `context.id === "default"` + branch in `buildRuntime()`. +2. Reduces env-var surface — operators set `PROJECT_DIR` and we derive + everything else from `/.pi/`. +3. Fixes the existing FIXME in `projectRegistry.ts` about sessions + landing in `data/sessions/` instead of under `.pi/`. +4. Makes single and multi modes structurally identical at the registry + level. The only remaining mode difference is *where `projectDir` + comes from* (boot env vs request header) — the actual trust-boundary + distinction we want to keep. + +## Convention + +| Tier | Location | Owner | Contents | +|---|---|---|---| +| **Org-shared (`agentDir`)** | `~/.pi/agent/` | process-global, every runtime references the same instances | `auth.json`, `models.json` | +| **Project (`piDir`)** | `/.pi/` | per-runtime — **including the default runtime** | `AGENTS.md`, `sessions/`, `skills/`, `extensions/`, `settings.json` | + +Key point: agent-server's contract has only **two locations** — a +shared org tier (just credentials + model registry, the genuinely +org-scoped state) and a per-runtime project tier. Everything +project-local, even for the default runtime, lives under that +runtime's `/.pi/`. Pi additionally auto-discovers +user-level resources from `~/.pi/agent/skills/`, `~/.agents/skills/`, +etc. if a user has them lying around — agent-server inherits that for +free but does not prescribe or rely on it. + +The default runtime is no longer structurally special — its `projectDir` +just happens to be set from boot env (`PROJECT_DIR`) instead of a +request header. In multi mode the host root's `.pi/` is typically +empty; nothing loads, the org-shared tier handles auth/models, and the +runtime is never routed to anyway. + +## Semantic change worth flagging + +`agentsFile` semantics split into "explicit" vs "convention default": + +- **Explicitly configured** (`config.agentsFile` set, or test fixture + passes a path): missing file is a **fatal** startup error. Preserves + "misconfiguration is loud". +- **Convention default** (`config.agentsFile` unset, falls back to + `/.pi/AGENTS.md`): missing file is a **silent skip**. The + runtime starts with no pinned prompt and Pi's normal context-file + discovery (suppressed only when a prompt is pinned) takes over. + +This replaces the current `defaultAgentsFile: false` kill switch. + +## Code changes + +### `src/runtime/projectRuntime.ts` + +- `ProjectRuntimeConfig.sessionsDir` → optional. Default + `/.pi/sessions/`. +- `ProjectRuntimeConfig.agentsFile` stays optional but with two-mode + semantics above. +- `readPinnedSystemPrompt()` → `resolveSystemPrompt()`: when explicit, + read & throw on missing; when default, `existsSync` check first, + return `undefined` if absent. Doc the split. + +### `src/runtime/projectRegistry.ts` + +- Drop `defaultAgentsFile` field on `ProjectRegistryConfig`. +- Drop `projectExtensionPaths` field — Pi already auto-discovers + `.pi/extensions/` from `cwd`, so the + `[".pi/extensions/appx-guardrails.ts"]` default is redundant. Keep + the comment elsewhere if we ever need to re-add explicit injection. +- Drop the `context.id === "default"` branches in `buildRuntime()` for + both `agentsFile` and `sessionsDir`. Default and per-project runtimes + call `ProjectRuntime.create()` with identical config shape. +- `sessionsDir` no longer threaded through the registry — runtime + derives. + +### `src/config.ts` + +Clean break (private package, controlled consumers): + +- Drop env vars: `SESSIONS_DIR`, `AGENTS_FILE`. +- Keep: `PROJECT_DIR` (required), `AGENT_DIR` (test/CI override of the + global tier), `PI_EXTENSION_PATHS` / `PI_SKILL_PATHS` / + `PI_PROMPT_PATHS` / `PI_THEME_PATHS` (operator-level *additional* + overlays, distinct from auto-discovery), `PI_NO_*`, server vars. +- Drop `agentsFile`, `sessionsDir` fields on `ServerConfig`. + +### `src/server.ts` + +- Stop passing `agentsFile`, `sessionsDir`, `defaultAgentsFile` to + `ProjectRegistry.create()`. +- Update startup logs to reflect the convention (log + `/.pi/` once instead of separate paths). +- Mode-branching for route mounting unchanged (`/v1/sessions` vs + `/v1/projects/:projectId/sessions`). + +### `src/openapi.ts` + +- Drop `defaultAgentsFile: false`. Stub registry only needs + `projectDir` + a silent logger. + +## Tests + +### `test/server.test.ts` + +- `startServer()`: drop `sessionsDir` and `agentsFile`. Keep + `agentDir: /.pi-agent` for per-test global-tier isolation. +- Multi-mode test (line 765+): drop `defaultAgentsFile: false` and + `sessionsDir`. The host-root `.pi/AGENTS.md` happens to exist in + `makeProject()` so the default runtime will load a prompt — that's + fine, it's never routed to. +- Project-isolation test (line 814+): drop `sessionsDir` and + `agentsFile`. +- Per-project session storage test now uses `/.pi/sessions` + (transparent to the test — it just hits the API). + +### `test/projectRuntimeServices.test.ts` + +- Drop explicit `sessionsDir` and `agentsFile` from happy-path tests + (rely on convention). +- Keep the `agentsFile: ".pi/does-not-exist.md"` test — it validates + the **explicit-override fatal** path. +- Add: convention-default silent-skip test. Build a project *without* + `.pi/AGENTS.md`, assert `ProjectRuntime.create()` succeeds and + `services.diagnostics` doesn't contain a prompt-load error. + +## Docs + +- README.md: env table loses `SESSIONS_DIR`, `AGENTS_FILE`. Add a short + "Filesystem layout" subsection. Update library-mode example (drop + `defaultAgentsFile`, drop `sessionsDir`). +- `docs/architecture/agent-server-layers.md`: update the table that + mentions `PROJECT_DIR/data/...` for sessions. +- `docs/misc/other/single-vs-multi-mode.md`: simplify the registry-API + table — no more `defaultAgentsFile` row. + +## Migration note for operators + +Existing deployments with sessions under `/data/sessions/` +will appear to lose history after upgrade because the runtime now +reads from `/.pi/sessions/`. One-line migration: + +```bash +mkdir -p "$PROJECT_DIR/.pi" +mv "$PROJECT_DIR/data/sessions" "$PROJECT_DIR/.pi/sessions" +``` + +To call out in README. `AGENTS.md` placement is unchanged +(`.pi/AGENTS.md` was already the documented default). + +## Out of scope + +- Renaming `agentDir` (Pi's term, kept). +- Touching extension discovery beyond removing the redundant + `projectExtensionPaths` default. +- Project-creation endpoint (separate FIXME on `server.ts:63`). diff --git a/docs/superpowers/plans/project-runtime-and-session-split.md b/docs/superpowers/plans/project-runtime-and-session-split.md new file mode 100644 index 0000000..834981c --- /dev/null +++ b/docs/superpowers/plans/project-runtime-and-session-split.md @@ -0,0 +1,982 @@ +# Refactor: Split `AgentRuntime` into `ProjectRuntime` + `ProjectSession` + +## Status + +**Landed** as of 2026-05-31. + +Implementation matches the plan as written. Minor additions not in the plan: + +- `AgentRuntimeResolver` was renamed to `ProjectRuntimeResolver` (in `routes.ts`) for consistency with the surrounding rename. Small cleanup that fell out of the rename. +- `ProjectSession.dispose()` is implemented but not yet called from production code; it exists as a future hook for idle eviction and is exercised by unit tests. + +All 60 tests pass (was 41/41 before; +19 new ProjectSession unit tests including the cross-session ExtensionUI isolation regression). + +## Goal + +Eliminate the conflation in `src/runtime.ts` where `AgentRuntime` mixes **project-level concerns** (shared resources, session collection, project paths) with **session-level concerns** (extension UI plumbing, prompt dispatch, per-session lifecycle). + +After this refactor: + +- **`ProjectRuntime`** owns everything project-scoped: paths, resource loaders, the `Map`, session creation/lookup/listing, and shared references to `AuthStorage` / `ModelRegistry` / `AgentCredentialsService` provided by the registry. +- **`ProjectSession`** owns everything session-scoped: the `AgentSession` instance, event subscription to the SSE broker, extension binding, the `pendingExtensionUi` map, the `ExtensionUIContext` implementation, and per-session operations (`sendPrompt`, `abort`, `setModel`, `setThinkingLevel`, model-settings reads). + +Routes become a thin two-step adapter: look up the project session, then call a method on it. + +## Why (Recap) + +1. **Single Responsibility Principle.** Today `AgentRuntime` changes for two unrelated reasons: project-level changes (paths, resource sharing) and session-level changes (extension UI, prompt handling). Two responsibilities → two classes. +2. **Implicit context, not threaded sessionId.** `createExtensionUiContext(sessionId)`, `createDialogPromise(sessionId, ...)`, `pendingExtensionUiRequests(id)`, `resolveExtensionUiRequest(id, requestId, response)` all carry a `sessionId` parameter that's actually `this.sessionId` once the per-session class exists. +3. **Routes self-document.** `await runtime.sendPrompt(id, text)` hides a session lookup. `await (await project.getSession(id))?.sendPrompt(text)` makes the two-step nature explicit. +4. **Aligns with the architectural drawings.** `docs/architecture/builder-container-architecture.md` already describes "per-project AgentRuntime, per-session AgentSession" — this refactor makes the code shape match the diagram. +5. **Testability.** `ProjectSession` can be unit-tested with a mock `AgentSession` and a mock publish function; today you need a full `AgentRuntime` with project plumbing. + +This refactor is **independent of and preferable to** swapping `AgentSession` for Pi's `AgentSessionRuntime`. See `docs/architecture/adapter-pattern-explained.md` for why we don't import `AgentSessionRuntime` even after this split. + +## Target Architecture + +``` +HTTP routes (routes.ts) ← thin adapter, no business logic + │ + │ resolves project from c + ▼ +AgentRuntimeRegistry ← unchanged: Map + │ + │ Map + ▼ +ProjectRuntime ← project-level (was AgentRuntime, partially) + • projectDir, sessionsDir, agentsFile + • shared AuthStorage / ModelRegistry / AgentCredentialsService + • Map + • createNewSession() → ProjectSession + • getSession(id) → ProjectSession | null (was ensureSession) + • listSessions() → SessionRow[] + • makeResourceLoader(), sessionModelDefaults() (private helpers) + │ + │ owns N + ▼ +ProjectSession ← session-level (NEW) + • session: AgentSession + • sessionId, boundAt, diagnostics + • extensionsReady: Promise + • sendPrompt(text) + • abort() + • setModel(provider, modelId) / setThinkingLevel(level) / updateModelSettings(...) + • getMessages(), getModelSettings() + • pendingExtensionUiRequests() + • resolveExtensionUiRequest(requestId, response) + • dispose() + • PRIVATE: createExtensionUiContext(), createDialogPromise(), commandActions() + │ + │ wraps + ▼ +AgentSession (Pi) ← unchanged +``` + +## Current State + +`src/runtime.ts` contains a single `AgentRuntime` class with: + +| Lines (approx) | Member | Belongs in | +|----------------|--------|------------| +| `projectDir`, `sessionsDir`, `agentDir`, `credentials`, `authStorage`, `modelRegistry`, `defaultModel*`, `extensionPaths`, `skillPaths`, `noExtensions`, `agentsFile`, `systemPrompt` | `ProjectRuntime` | +| `live: Map` | `ProjectRuntime` (with element type `ProjectSession` instead of `LiveSession`) | +| `pendingExtensionUi: Map` | `ProjectSession` (split per-session, `sessionId` field becomes implicit) | +| `sessionModelSettings(session)` | `ProjectSession` (it operates on a single session) | +| `sessionModelDefaults()` | `ProjectRuntime` (project-level config feeding session creation) | +| `makeResourceLoader()` | `ProjectRuntime` | +| `publishExtensionUiRequest(sessionId, request)` | `ProjectSession` (becomes `publish(request)`) | +| `createDialogPromise(sessionId, ...)` | `ProjectSession` (drops the `sessionId` arg) | +| `createExtensionUiContext(sessionId)` | `ProjectSession` (drops the `sessionId` arg) | +| `extensionCommandActions(session)` | `ProjectSession` (becomes `commandActions()`, uses `this.session`) | +| `bind(session)` | `ProjectSession` constructor | +| `ensureExtensionsReady(id)` | `ProjectSession` (becomes `await this.extensionsReady`) | +| `pendingExtensionUiRequests(id)` | `ProjectSession.pendingExtensionUiRequests()` | +| `resolveExtensionUiRequest(id, requestId, response)` | `ProjectSession.resolveExtensionUiRequest(requestId, response)` | +| `createNewSession()` | `ProjectRuntime` (returns `ProjectSession` now, not `{ id, createdAt }`) | +| `ensureSession(id)` | `ProjectRuntime.getSession(id)` (returns `ProjectSession | null`) | +| `listSessions()` | `ProjectRuntime` | +| `getSessionMessages(id)` | `ProjectSession.getMessages()` (route looks up session first) | +| `getSessionModelSettings(id)` | `ProjectSession.getModelSettings()` | +| `setSessionModel(id, ...)` / `setSessionThinkingLevel(id, ...)` / `updateSessionModelSettings(id, ...)` | `ProjectSession.setModel(...)` / `setThinkingLevel(...)` / `updateModelSettings(...)` | +| `sendPrompt(id, text)` | `ProjectSession.sendPrompt(text)` | +| `abortSession(id)` | `ProjectSession.abort()` | + +Key observation: the `LiveSession` type and `PendingExtensionUiRequest` type both disappear — they're absorbed into `ProjectSession` as private fields. + +## Target File Layout + +``` +src/ +├── projectRuntime.ts ← NEW. Renamed from runtime.ts, project-level only. +├── projectSession.ts ← NEW. Per-session class. +├── extensionUi.ts ← NEW. ExtensionUiRequest / ExtensionUiResponse types. +├── runtimeRegistry.ts ← UPDATED. Imports ProjectRuntime instead of AgentRuntime. +├── routes.ts ← UPDATED. Two-step lookup; method calls move to ProjectSession. +├── server.ts ← UPDATED. Type imports. +├── credentialsService.ts ← UNCHANGED. +├── sseBroker.ts ← UNCHANGED. +├── thinking.ts ← UNCHANGED. +├── schemas.ts ← UNCHANGED. +├── litellm.ts ← UNCHANGED. +├── openapi.ts ← UNCHANGED. +└── index.ts ← UPDATED. Public exports. +``` + +`runtime.ts` is **removed**. Its contents are split into `projectRuntime.ts` and `projectSession.ts`. + +We extract the `ExtensionUiRequest` / `ExtensionUiResponse` discriminated unions to `extensionUi.ts` because both `projectSession.ts` (emits) and `routes.ts` (receives via the response endpoint) reference them. + +## File-by-File Plan + +### 1. NEW `src/extensionUi.ts` + +Move the two type unions out of `runtime.ts` (lines ~189–220) verbatim. Re-export from `index.ts`. + +```typescript +/** + * Extension UI request/response types for SSE transport. + * Mirrors Pi's RpcExtensionUI* types from + * @earendil-works/pi-coding-agent/modes/rpc, but kept locally because Pi + * doesn't export them from its public API. + */ + +import type { WidgetPlacement } from "@earendil-works/pi-coding-agent"; + +export type ExtensionUiRequest = + | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } + | { type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" } + | { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } + | { type: "extension_ui_request"; id: string; method: "setWidget"; widgetKey: string; widgetLines: string[] | undefined; widgetPlacement?: WidgetPlacement } + | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } + | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }; + +export type ExtensionUiResponse = + | { value: string } + | { confirmed: boolean } + | { cancelled: true }; +``` + +### 2. NEW `src/projectSession.ts` + +The new per-session class. Roughly 350 lines, absorbed from `runtime.ts`. + +```typescript +/** + * ProjectSession — owns one AgentSession and all per-session concerns: + * event publishing, extension binding, ExtensionUIContext implementation, + * extension-UI request/response routing, and per-session operations + * (prompt, abort, model/thinking changes, message reads). + * + * Lifecycle: created by ProjectRuntime when a session is first bound + * (created or lazily reopened). The constructor immediately subscribes + * to AgentSession events and kicks off bindExtensions(); callers can + * await `extensionsReady` before issuing the first prompt to ensure + * extension `session_start` handlers have run. + * + * Disposal: call `dispose()` to unsubscribe from events, cancel pending + * extension UI requests, and tear the session down. Currently unused + * outside of testing — production keeps sessions live for the lifetime + * of the runtime — but kept for symmetry with Pi's AgentSessionRuntime + * teardown discipline and to give us a clean hook if we add idle eviction + * later. + */ + +import { randomUUID } from "node:crypto"; +import { + type AgentSession, + type AgentSessionEvent, + type ExtensionCommandContextActions, + type ExtensionUIContext, + type ExtensionUIDialogOptions, + type ExtensionWidgetOptions, +} from "@earendil-works/pi-coding-agent"; +import type { AgentCredentialsService, AgentModelRow } from "./credentialsService.js"; +import type { ExtensionUiRequest, ExtensionUiResponse } from "./extensionUi.js"; +import { publish } from "./sseBroker.js"; +import { + type ThinkingLevel, + supportedThinkingLevelsForModel, +} from "./thinking.js"; + +type SessionModel = NonNullable[0]>; + +export type SessionModelSettings = { + model: AgentModelRow | null; + thinkingLevel: ThinkingLevel; + availableThinkingLevels: ThinkingLevel[]; + supportsThinking: boolean; + isStreaming: boolean; +}; + +type PendingExtensionUiRequest = { + request: ExtensionUiRequest; + resolve: (response: ExtensionUiResponse) => void; + timer?: ReturnType; + abort?: () => void; +}; + +export type ProjectSessionDeps = { + credentials: AgentCredentialsService; + modelRegistry: Pick< + import("@earendil-works/pi-coding-agent").ModelRegistry, + "find" + >; + logger: Pick; +}; + +export class ProjectSession { + readonly session: AgentSession; + readonly sessionId: string; + readonly boundAt: string; + readonly extensionsReady: Promise; + + private readonly deps: ProjectSessionDeps; + private readonly pendingExtensionUi = new Map(); + private readonly unsubscribeEvents: () => void; + private disposed = false; + + constructor(session: AgentSession, deps: ProjectSessionDeps) { + this.session = session; + this.sessionId = session.sessionId; + this.deps = deps; + this.boundAt = new Date().toISOString(); + + // Per-session SSE bridge. publish() routes by sessionId on the broker. + this.unsubscribeEvents = session.subscribe((event: AgentSessionEvent) => { + publish(this.sessionId, event); + }); + + // Bind extensions with our session-scoped UI context. We hold the + // promise so sendPrompt() can await it and so disposers can join. + this.extensionsReady = session + .bindExtensions({ + uiContext: this.createExtensionUiContext(), + commandContextActions: this.commandActions(), + onError: (err) => { + publish(this.sessionId, { + type: "extension_error", + extensionPath: err.extensionPath, + event: err.event, + error: err.error, + stack: err.stack, + }); + this.deps.logger.error( + `[agent] extension error in ${err.extensionPath}: ${err.error}`, + ); + }, + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + publish(this.sessionId, { + type: "extension_error", + extensionPath: "", + event: "session_start", + error: message, + }); + this.deps.logger.error( + `[agent] extension binding failed for ${this.sessionId}: ${message}`, + ); + }); + } + + // ───────────────────────────────────────────────────────────────── + // Session operations + // ───────────────────────────────────────────────────────────────── + + getMessages(): unknown[] { + return this.session.state.messages; + } + + getModelSettings(): SessionModelSettings { + return { + model: this.session.model + ? this.deps.credentials.modelRow(this.session.model as SessionModel) + : null, + thinkingLevel: this.session.thinkingLevel as ThinkingLevel, + availableThinkingLevels: this.session.getAvailableThinkingLevels() as ThinkingLevel[], + supportsThinking: this.session.supportsThinking(), + isStreaming: this.session.isStreaming, + }; + } + + async setModel(provider: string, modelId: string): Promise { + if (this.session.isStreaming) + throw new Error("Cannot change model while the agent is running"); + const model = this.deps.modelRegistry.find(provider, modelId) as + | SessionModel + | undefined; + if (!model) throw new Error(`model ${provider}/${modelId} not found`); + await this.applyModel(model); + return this.getModelSettings(); + } + + setThinkingLevel(level: ThinkingLevel): SessionModelSettings { + if (this.session.isStreaming) + throw new Error("Cannot change thinking level while the agent is running"); + this.session.setThinkingLevel(level); + return this.getModelSettings(); + } + + async updateModelSettings(settings: { + provider?: string; + modelId?: string; + thinkingLevel?: ThinkingLevel; + }): Promise { + if (this.session.isStreaming) + throw new Error("Cannot change model settings while the agent is running"); + if (settings.provider && settings.modelId) { + const model = this.deps.modelRegistry.find( + settings.provider, + settings.modelId, + ) as SessionModel | undefined; + if (!model) + throw new Error(`model ${settings.provider}/${settings.modelId} not found`); + await this.applyModel(model); + } + if (settings.thinkingLevel) this.session.setThinkingLevel(settings.thinkingLevel); + return this.getModelSettings(); + } + + async sendPrompt(text: string): Promise { + await this.extensionsReady; + if (this.session.isStreaming) { + // Steer interrupts the current turn after current tool calls finish, + // rather than waiting for it to fully stop (which followUp does). + await this.session.prompt(text, { streamingBehavior: "steer" }); + return; + } + await this.session.prompt(text); + } + + async abort(): Promise { + if (!this.session.isStreaming) return; + await this.session.abort(); + } + + // ───────────────────────────────────────────────────────────────── + // Extension UI request routing + // ───────────────────────────────────────────────────────────────── + + pendingExtensionUiRequests(): ExtensionUiRequest[] { + return Array.from(this.pendingExtensionUi.values()).map((entry) => entry.request); + } + + resolveExtensionUiRequest( + requestId: string, + response: ExtensionUiResponse, + ): boolean { + const pending = this.pendingExtensionUi.get(requestId); + if (!pending) return false; + pending.resolve(response); + return true; + } + + // ───────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────── + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.unsubscribeEvents(); + for (const pending of this.pendingExtensionUi.values()) { + if (pending.timer) clearTimeout(pending.timer); + pending.abort?.(); + pending.resolve({ cancelled: true }); + } + this.pendingExtensionUi.clear(); + // session.dispose() may not exist on AgentSession — call whatever + // teardown Pi exposes if/when we need it. For now, dropping our + // references is sufficient. + } + + // ───────────────────────────────────────────────────────────────── + // Private + // ───────────────────────────────────────────────────────────────── + + private async applyModel(model: SessionModel): Promise { + const currentThinkingLevel = this.session.thinkingLevel as ThinkingLevel; + const nextAvailableLevels = supportedThinkingLevelsForModel(model); + const defaultThinkingLevel = this.deps.credentials.defaultThinkingForModel(model); + const shouldUseModelDefault = Boolean( + defaultThinkingLevel && !nextAvailableLevels.includes(currentThinkingLevel), + ); + await this.session.setModel(model); + if (shouldUseModelDefault && this.session.thinkingLevel !== defaultThinkingLevel) { + this.session.setThinkingLevel(defaultThinkingLevel!); + } + } + + private commandActions(): ExtensionCommandContextActions { + return { + waitForIdle: () => this.session.agent.waitForIdle(), + newSession: async () => ({ cancelled: true }), + fork: async () => ({ cancelled: true }), + navigateTree: async () => ({ cancelled: true }), + switchSession: async () => ({ cancelled: true }), + reload: async () => { + await this.session.reload(); + }, + }; + } + + /** + * Create a session-scoped ExtensionUIContext. All pending UI requests + * route back to this ProjectSession; the SSE broker fans them out to + * subscribers of this sessionId. + */ + private createExtensionUiContext(): ExtensionUIContext { + return { + select: (title, options, opts) => + this.dialog(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, + (r) => ("cancelled" in r ? undefined : "value" in r ? r.value : undefined)), + confirm: (title, message, opts) => + this.dialog(opts, false, { method: "confirm", title, message, timeout: opts?.timeout }, + (r) => ("cancelled" in r ? false : "confirmed" in r ? r.confirmed : false)), + input: (title, placeholder, opts) => + this.dialog(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, + (r) => ("cancelled" in r ? undefined : "value" in r ? r.value : undefined)), + editor: (title, prefill) => + this.dialog(undefined, undefined, { method: "editor", title, prefill }, + (r) => ("cancelled" in r ? undefined : "value" in r ? r.value : undefined)), + notify: (message, type) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "notify", + message, + notifyType: type, + }), + onTerminalInput: () => () => {}, + setStatus: (key, text) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "setStatus", + statusKey: key, + statusText: text, + }), + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: ((key: string, content: string[] | ((...args: unknown[]) => unknown) | undefined, options?: ExtensionWidgetOptions) => { + if (content !== undefined && !Array.isArray(content)) return; + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "setWidget", + widgetKey: key, + widgetLines: content, + widgetPlacement: options?.placement, + }); + }) as ExtensionUIContext["setWidget"], + setFooter: () => {}, + setHeader: () => {}, + setTitle: (title) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "setTitle", + title, + }), + custom: async () => undefined as never, + pasteToEditor: (text) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "set_editor_text", + text, + }), + setEditorText: (text) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "set_editor_text", + text, + }), + getEditorText: () => "", + addAutocompleteProvider: () => {}, + setEditorComponent: () => {}, + getEditorComponent: () => undefined, + get theme() { + return undefined as never; + }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: () => ({ + success: false, + error: "UI theme switching is not available in agent-server", + }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, + }; + } + + private dialog( + opts: ExtensionUIDialogOptions | undefined, + fallback: T, + request: Record, + mapResponse: (response: ExtensionUiResponse) => T, + ): Promise { + const id = randomUUID(); + const event = { type: "extension_ui_request" as const, id, ...request } as ExtensionUiRequest; + + return new Promise((resolve) => { + const finish = (response: ExtensionUiResponse) => { + const pending = this.pendingExtensionUi.get(id); + if (!pending) return; + if (pending.timer) clearTimeout(pending.timer); + pending.abort?.(); + this.pendingExtensionUi.delete(id); + resolve(mapResponse(response)); + }; + + const pending: PendingExtensionUiRequest = { + request: event, + resolve: finish, + }; + + if (opts?.timeout && opts.timeout > 0) { + pending.timer = setTimeout(() => finish({ cancelled: true }), opts.timeout); + } + + if (opts?.signal) { + const onAbort = () => finish({ cancelled: true }); + opts.signal.addEventListener("abort", onAbort, { once: true }); + pending.abort = () => opts.signal?.removeEventListener("abort", onAbort); + } + + this.pendingExtensionUi.set(id, pending); + this.publishRequest(event); + }); + } + + private publishRequest(request: ExtensionUiRequest): void { + publish(this.sessionId, request); + } +} +``` + +Notes on this file: +- All `sessionId` parameter threading from `runtime.ts` is removed. `this.sessionId` does the routing. +- `ensureExtensionsReady` becomes `await this.extensionsReady` directly in `sendPrompt`. +- `dispose()` is added now (currently we have no equivalent — sessions live forever). It's a small addition that gives us a clean hook for future idle eviction and helps tests not leak event listeners. +- `commandActions()` is private because nothing outside the class needs it. + +### 3. NEW `src/projectRuntime.ts` (replaces `src/runtime.ts`) + +Project-level only. Roughly 250 lines, down from 777. + +```typescript +/** + * ProjectRuntime — pi SDK orchestrator scoped to one project. + * + * Each project gets one ProjectRuntime that: + * - Holds project-level config (projectDir, sessionsDir, agentsFile, …) + * - Holds shared references to AuthStorage, ModelRegistry, and + * AgentCredentialsService (provided by AgentRuntimeRegistry — these + * are process-global, not project-global) + * - Owns Map and is responsible for session + * creation, lazy reopen, and listing + * - Builds a fresh DefaultResourceLoader per session bind + * + * Per-session operations (prompt, abort, model changes, extension-UI + * routing) live on ProjectSession, not here. Routes look up the + * ProjectSession via getSession(id) and call methods on it directly. + */ + +import { mkdirSync, readFileSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import { + AuthStorage, + type CreateAgentSessionOptions, + createAgentSession, + DefaultResourceLoader, + type ExtensionFactory, + getAgentDir, + ModelRegistry, + type ModelRegistry as ModelRegistryType, + SessionManager, + type SessionInfo, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { AgentCredentialsService } from "./credentialsService.js"; +import { ProjectSession } from "./projectSession.js"; +import { type ThinkingLevel } from "./thinking.js"; + +type SessionModel = NonNullable; + +export type { + AgentAuthPrompt, + AgentAuthProviderRow, + AgentCustomProviderApi, + AgentCustomProviderModel, + AgentCustomProviderRow, + AgentModelRow, + AgentOAuthFlowState, + UpsertCustomProviderRequest, +} from "./credentialsService.js"; +export type { ExtensionUiRequest, ExtensionUiResponse } from "./extensionUi.js"; +export type { SessionModelSettings } from "./projectSession.js"; +export type { ThinkingLevel } from "./thinking.js"; + +export type ProjectRuntimeConfig = { + projectDir: string; + sessionsDir: string; + agentDir?: string; + credentials: AgentCredentialsService; + authStorage?: AuthStorage; + modelRegistry?: ModelRegistryType; + anthropicApiKey?: string; + configureModelRegistry?: (modelRegistry: ModelRegistryType) => void; + defaultModelProvider?: string; + defaultModelId?: string; + defaultThinkingLevel?: ThinkingLevel; + modelThinkingDefaults?: Record; + extensionPaths?: string[]; + skillPaths?: string[]; + promptTemplatePaths?: string[]; + themePaths?: string[]; + extensionFactories?: ExtensionFactory[]; + noExtensions?: boolean; + noSkills?: boolean; + noPromptTemplates?: boolean; + noThemes?: boolean; + agentsFile?: string; + logger?: Pick; +}; + +export type SessionRow = { + id: string; + createdAt: string; + firstMessage: string; + messageCount: number; +}; + +export class ProjectRuntime { + readonly credentials: AgentCredentialsService; + private readonly projectDir: string; + private readonly sessionsDir: string; + private readonly agentDir: string; + private readonly authStorage: AuthStorage; + private readonly modelRegistry: ModelRegistry; + private readonly logger: Pick; + private readonly defaultModelProvider: string | undefined; + private readonly defaultModelId: string | undefined; + private readonly defaultThinkingLevel: ThinkingLevel | undefined; + private readonly extensionPaths: string[]; + private readonly skillPaths: string[]; + private readonly promptTemplatePaths: string[]; + private readonly themePaths: string[]; + private readonly extensionFactories: ExtensionFactory[]; + private readonly noExtensions: boolean; + private readonly noSkills: boolean; + private readonly noPromptTemplates: boolean; + private readonly noThemes: boolean; + private readonly agentsFile: string | undefined; + private readonly systemPrompt: string | undefined; + private readonly sessions = new Map(); + + constructor(config: ProjectRuntimeConfig) { + // … same body as the current AgentRuntime constructor, with one + // change: ProjectSession-related fields (`live`, `pendingExtensionUi`) + // are gone. `sessions` replaces `live`. + // … (full code identical to current constructor; omitted here for brevity) + } + + // ── Session collection management ───────────────────────────────── + + async createNewSession(): Promise { + const { session } = await createAgentSession({ + ...this.sessionModelDefaults(), + authStorage: this.authStorage, + modelRegistry: this.modelRegistry, + sessionManager: SessionManager.create(this.projectDir, this.sessionsDir), + resourceLoader: await this.makeResourceLoader(), + }); + return this.adopt(session); + } + + /** + * Get a live ProjectSession, lazily reopening from disk if needed. + * Returns null if no session file exists with the given id. + */ + async getSession(id: string): Promise { + const existing = this.sessions.get(id); + if (existing) return existing; + + const sessions = await SessionManager.list(this.projectDir, this.sessionsDir); + const info = sessions.find((s) => s.id === id); + if (!info) return null; + + const { session } = await createAgentSession({ + ...this.sessionModelDefaults(), + authStorage: this.authStorage, + modelRegistry: this.modelRegistry, + sessionManager: SessionManager.open(info.path), + resourceLoader: await this.makeResourceLoader(), + }); + return this.adopt(session); + } + + async listSessions(): Promise { + const list: SessionInfo[] = await SessionManager.list( + this.projectDir, + this.sessionsDir, + ); + const onDisk = new Set(list.map((s) => s.id)); + + const rows: SessionRow[] = list.map((info) => ({ + id: info.id, + createdAt: info.created.toISOString(), + firstMessage: info.firstMessage ?? "", + messageCount: info.messageCount, + })); + + for (const [id, ps] of this.sessions) { + if (onDisk.has(id)) continue; + const messages = ps.session.state.messages as Array<{ + role: string; + content: Array<{ type: string; text?: string }>; + }>; + const firstUser = messages.find((m) => m.role === "user"); + const firstText = + firstUser?.content.find((c) => c.type === "text")?.text ?? ""; + rows.push({ + id, + createdAt: ps.boundAt, + firstMessage: firstText, + messageCount: messages.length, + }); + } + + return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + // ── Private helpers ─────────────────────────────────────────────── + + private adopt(session: import("@earendil-works/pi-coding-agent").AgentSession): ProjectSession { + const ps = new ProjectSession(session, { + credentials: this.credentials, + modelRegistry: this.modelRegistry, + logger: this.logger, + }); + this.sessions.set(ps.sessionId, ps); + return ps; + } + + private sessionModelDefaults(): Pick { + // … unchanged from current AgentRuntime.sessionModelDefaults() + } + + private async makeResourceLoader(): Promise { + // … unchanged from current AgentRuntime.makeResourceLoader() + } +} +``` + +Notes: +- `sendPrompt`, `abortSession`, `setSessionModel`, `setSessionThinkingLevel`, `updateSessionModelSettings`, `getSessionMessages`, `getSessionModelSettings`, `pendingExtensionUiRequests`, `resolveExtensionUiRequest`, `ensureSession`, `ensureExtensionsReady`, `bind`, `createDialogPromise`, `createExtensionUiContext`, `extensionCommandActions`, `publishExtensionUiRequest` are all **gone** from `ProjectRuntime`. Either moved to `ProjectSession` or replaced by the routes calling `await pr.getSession(id)` then a method on the returned `ProjectSession`. +- `LiveSession` and `PendingExtensionUiRequest` types are gone (absorbed). +- `ExtensionUiRequest` / `ExtensionUiResponse` types are re-exported from `extensionUi.ts` for backwards-compat with anything that was importing from `runtime.js`. + +### 4. UPDATED `src/runtimeRegistry.ts` + +Mechanical rename only. ~5 line diff. + +```typescript +// Change all imports of AgentRuntime / AgentRuntimeConfig to +// ProjectRuntime / ProjectRuntimeConfig from "./projectRuntime.js". +// +// Rename: +// class AgentRuntimeRegistry → unchanged (the registry stays this name) +// type AgentRuntimeRegistryConfig → unchanged (registry config name stays) +// type RuntimeEntry's runtime: AgentRuntime → runtime: ProjectRuntime +// defaultRuntime: AgentRuntime → defaultRuntime: ProjectRuntime +// forProject(...) return type → ProjectRuntime +// private createRuntime(...) return type → ProjectRuntime +// new AgentRuntime(...) → new ProjectRuntime(...) +``` + +We **keep** `AgentRuntimeRegistry` named as-is — it's the registry of project runtimes, and renaming it would ripple into appx's Go proxy code. The mental model is "registry of runtimes", which stays accurate. + +### 5. UPDATED `src/routes.ts` + +The pattern in every session-scoped handler changes from one-step to two-step lookup. Roughly 30 line diff total across the file. + +**Before** (current pattern): +```typescript +app.openapi(getMessagesRoute, async (c) => { + const runtime = await getRuntime(c); + const id = c.req.param("id"); + const messages = await runtime.getSessionMessages(id); + if (messages === null) return c.json({ error: "session not found" }, 404); + return c.json({ messages }, 200); +}); +``` + +**After**: +```typescript +app.openapi(getMessagesRoute, async (c) => { + const runtime = await getRuntime(c); + const id = c.req.param("id"); + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + return c.json({ messages: session.getMessages() }, 200); +}); +``` + +Specific call-site rewrites: + +| Current | New | +|---------|-----| +| `await runtime.listSessions()` | unchanged | +| `await runtime.createNewSession()` returns `{ id, createdAt }` | `const session = await runtime.createNewSession();` then build `{ id: session.sessionId, createdAt: session.boundAt }` | +| `await runtime.getSessionModelSettings(id)` | `const s = await runtime.getSession(id); if (!s) 404; return s.getModelSettings();` | +| `await runtime.updateSessionModelSettings(id, body)` | `const s = await runtime.getSession(id); if (!s) 404; return await s.updateModelSettings(body);` | +| `await runtime.getSessionMessages(id)` | `const s = await runtime.getSession(id); if (!s) 404; return s.getMessages();` | +| `await runtime.ensureSession(id)` then `runtime.pendingExtensionUiRequests(id)` | `const s = await runtime.getSession(id); if (!s) 404; return s.pendingExtensionUiRequests();` | +| `runtime.resolveExtensionUiRequest(id, requestId, body)` | `const s = await runtime.getSession(id); if (!s) 404; const ok = s.resolveExtensionUiRequest(requestId, body);` | +| `await runtime.ensureSession(id)` then `runtime.sendPrompt(id, text)` | `const s = await runtime.getSession(id); if (!s) 404; void s.sendPrompt(text).catch(...);` | +| `await runtime.abortSession(id)` | `const s = await runtime.getSession(id); if (!s) 404; await s.abort();` | +| SSE `for (const request of runtime.pendingExtensionUiRequests(id))` | `for (const request of session.pendingExtensionUiRequests())` (variable already in scope from existing `runtime.ensureSession(id)` call, which becomes `runtime.getSession(id)`) | + +The SSE handler is the most interesting case — `getSession` returning `ProjectSession` instead of `AgentSession` means the existing `session.pendingExtensionUiRequests()` call works directly. No extra plumbing. + +### 6. UPDATED `src/server.ts` + +Mechanical: rename type imports `AgentRuntime` → `ProjectRuntime` and update the resolver type. ~5 line diff. + +### 7. UPDATED `src/index.ts` + +Public API rename. Whatever consumers are importing (`AgentRuntime`, `AgentRuntimeConfig`) either needs aliasing or a deprecation cycle. + +**Recommended approach** (since this is an internal-to-appx package): direct rename, no compatibility shim. Update appx in the same commit if it imports from agent-server's types (it doesn't appear to — it talks via HTTP). + +```typescript +export { ProjectRuntime, type ProjectRuntimeConfig, type SessionRow } from "./projectRuntime.js"; +export { ProjectSession, type SessionModelSettings } from "./projectSession.js"; +export type { ExtensionUiRequest, ExtensionUiResponse } from "./extensionUi.js"; +export { AgentRuntimeRegistry, type AgentRuntimeRegistryConfig } from "./runtimeRegistry.js"; +// … existing credentials / litellm / openapi exports unchanged +``` + +### 8. UPDATED `test/server.test.ts` + +The integration tests construct `AgentRuntimeRegistry` and `AgentRuntime` directly. Two name changes: + +```typescript +// Before +import { AgentRuntime } from "../src/runtime.js"; +new AgentRuntime({ ... }) + +// After +import { ProjectRuntime } from "../src/projectRuntime.js"; +new ProjectRuntime({ ... }) +``` + +Test bodies don't care which methods are on which class because they drive everything through `fetch` against the real HTTP routes. Signature changes are transparent at the HTTP level. + +## Step-by-Step Implementation Order + +Designed so `npm run check` passes after each step. + +1. **Create `src/extensionUi.ts`** with the two type unions. + - Re-export them from `src/runtime.ts` so nothing breaks yet. + - Verify: `npm run check` passes. + +2. **Create `src/projectSession.ts`** with the `ProjectSession` class. + - Don't wire it up yet. Just compile it. + - Adapt the existing `bind`, `createExtensionUiContext`, `createDialogPromise`, `extensionCommandActions`, `pendingExtensionUiRequests`, `resolveExtensionUiRequest`, `sessionModelSettings`, `setSessionModelInternal`, `sendPrompt`, `abortSession`, `setSessionModel`, `setSessionThinkingLevel`, `updateSessionModelSettings`, `getSessionMessages`, `getSessionModelSettings` logic into class methods that drop the `id`/`sessionId` parameter and use `this`. + - Verify: `npm run check` passes (file compiles even though nothing uses it yet). + +3. **Wire `ProjectRuntime` (still named `AgentRuntime`) to use `ProjectSession`.** + - Inside `runtime.ts`, replace `live: Map` with `sessions: Map`. + - Replace `bind(session)` with `adopt(session)` that constructs a `ProjectSession`. + - Make `AgentRuntime`'s session-operation methods delegate to the matching `ProjectSession` method (transitional; the methods stay on `AgentRuntime` for now). + - Delete the now-unused private fields (`pendingExtensionUi`) and helpers (`createDialogPromise`, `createExtensionUiContext`, `publishExtensionUiRequest`, `extensionCommandActions`, `ensureExtensionsReady`, `bind`). + - Verify: existing tests pass. SSE traffic still flows. + +4. **Push session-operation methods off `AgentRuntime` into routes.** + - In `routes.ts`, replace each `await runtime.x(id, ...)` with `const s = await runtime.getSession(id); if (!s) 404; await s.x(...)`. Add `getSession` as an alias of `ensureSession` returning the new type. + - Remove the corresponding methods from `AgentRuntime` once routes stop calling them. + - Verify: existing tests pass. + +5. **Rename `AgentRuntime` → `ProjectRuntime`, file `runtime.ts` → `projectRuntime.ts`.** + - Update imports in `runtimeRegistry.ts`, `server.ts`, `index.ts`, `test/server.test.ts`. + - Drop the temporary re-exports from step 1. + - Verify: `npm run check` passes, all tests green. + +6. **Add new tests for `ProjectSession`** (see "Tests" below). + - Verify: full suite green. + +7. **Update internal docs** (`docs/architecture/pi-component-responsibilities.md`, `docs/architecture/extension-ui-implementation-comparison.md`, `docs/architecture/builder-container-architecture.md`) so the class names match the new code. + +Each step is independently shippable and reviewable. If we have to stop midway, the system stays working. + +## Tests + +### New unit tests for `ProjectSession` + +`test/projectSession.test.ts` (new file) — exercises the new class in isolation with a fake `AgentSession` and a spy `publish` function. + +Coverage targets: +- Constructor subscribes to events and forwards them to `publish` with the correct sessionId. +- Constructor calls `bindExtensions`; `extensionsReady` resolves on success and on bind error (with `extension_error` published). +- `sendPrompt` awaits `extensionsReady` before delegating, and uses `streamingBehavior: "steer"` when already streaming. +- `abort` is a no-op when not streaming, calls `session.abort()` otherwise. +- `setModel` rejects while streaming; rejects unknown model; calls `setModel` and applies thinking-level default when current level isn't supported by the new model. +- `setThinkingLevel` rejects while streaming. +- `updateModelSettings` applies model and thinking changes atomically; rejects while streaming. +- ExtensionUI dialog flow: `select` returns the value on response; returns `undefined` on `cancelled: true`; honors `timeout`; honors abort signal. +- `pendingExtensionUiRequests()` returns currently-open dialogs. +- `resolveExtensionUiRequest(requestId, response)` returns false for unknown id, true on success, removes the entry. +- `dispose()` cancels pending dialogs (resolving them with `cancelled: true`) and unsubscribes events. + +### Updated integration tests in `test/server.test.ts` + +- Two import name changes (`AgentRuntime` → `ProjectRuntime`). +- One semantic check to add: a regression that **confirms two concurrent sessions in the same project don't cross-pollinate ExtensionUI requests** (i.e., resolving session A's request shouldn't affect session B's pending). This was implicit before; with separate `pendingExtensionUi` maps per `ProjectSession`, the property is now structural. + +## Risks and Mitigations + +| Risk | Likelihood | Mitigation | +|------|-----------|-----------| +| Breaking external consumers of `AgentRuntime` exports | Low | appx talks via HTTP, not via type imports. Confirmed by grepping `appx/internal/server/agent_proxy.go` — no Node imports. | +| Subtle behavior change in extension binding ordering | Low | Constructor calls `bindExtensions` synchronously, same as current `bind()`. The `extensionsReady` promise has identical semantics to today's `extensionsReady`. | +| SSE event ordering changes | Very low | The subscribe call moves from `bind()` to `ProjectSession` constructor, but happens at the same point in the lifecycle (immediately on session creation/reopen). No reordering. | +| New `dispose()` method introduces a way to leak / double-dispose | Low | Guarded by `disposed` flag. Currently unused outside tests. | +| Type churn breaks `npm run check` mid-refactor | Medium | The step-by-step plan above is explicitly designed so each step compiles before moving on. Re-export shims in step 1 cover the transition. | +| Test instability from the new ProjectSession unit tests | Medium | Use a hand-written fake `AgentSession`, not real Pi internals. Keep tests deterministic with explicit timing controls. | + +## Out of Scope + +These are deliberately **not** part of this refactor and should be separate proposals: + +1. Importing Pi's `AgentSessionRuntime`. We considered and rejected this in `docs/architecture/adapter-pattern-explained.md`. Don't combine with this refactor. +2. Adding a separate "http-mode" file/concept. `routes.ts` already plays that structural role. +3. Idle session eviction. The new `dispose()` enables it cleanly, but the policy decision is separate. +4. Multi-user authorization on session ids. Currently any caller with a project's auth header can resolve any session id in that project; that's an appx-side concern. +5. Renaming `AgentRuntimeRegistry`. The name is still accurate ("registry of project runtimes") and renaming touches more files than the win is worth. + +## Estimated Size + +- New code: ~400 lines (`projectSession.ts` + `extensionUi.ts`). +- Removed code: ~450 lines (carved out of `runtime.ts`). +- Modified code: ~50 lines (`routes.ts`, `runtimeRegistry.ts`, `server.ts`, `index.ts`, `test/server.test.ts`). +- New tests: ~250 lines (`test/projectSession.test.ts`). + +Net: roughly +150 lines, but with the existing 777-line `runtime.ts` replaced by a ~250-line `projectRuntime.ts` plus a focused ~400-line `projectSession.ts`. Both new files are easier to read in isolation than the current monolith. + +## Done When + +1. `src/runtime.ts` no longer exists. +2. `ProjectRuntime` exposes only project-level methods (`createNewSession`, `getSession`, `listSessions`); no method takes a `sessionId` argument. +3. `ProjectSession` exposes only session-level methods; no method takes a `sessionId` argument (it's `this.sessionId`). +4. `routes.ts` follows the two-step `getSession` → method-on-session pattern uniformly. +5. `npm run check` is green; all existing tests pass; new `ProjectSession` unit tests are added and green. +6. Updated docs reference the new class names. diff --git a/docs/superpowers/plans/use-agent-session-services.md b/docs/superpowers/plans/use-agent-session-services.md new file mode 100644 index 0000000..7146f70 --- /dev/null +++ b/docs/superpowers/plans/use-agent-session-services.md @@ -0,0 +1,611 @@ +# Refactor: Adopt `AgentSessionServices` in `ProjectRuntime` + +## Status + +**Landed.** Implemented on the `pi-switch-refactor` branch alongside the +project-runtime/session split. See `src/projectRuntime.ts`, +`src/runtimeRegistry.ts`, and the `ProjectRuntime — AgentSessionServices +integration` test suite in `test/projectRuntimeServices.test.ts`. + +## Goal + +Replace `ProjectRuntime`'s individually-held service references (`authStorage`, `modelRegistry`, `agentDir`, plus per-session-recreated `SettingsManager` and `ResourceLoader`) with a single `services: AgentSessionServices` bundle constructed via Pi's `createAgentSessionServices()`. + +After this refactor: + +- One `ResourceLoader` and one `SettingsManager` per project, reused across all sessions in that project (instead of recreated per session). +- Session creation goes through `createAgentSessionFromServices()` instead of the lower-level `createAgentSession()`. +- Pi's `AgentSessionRuntimeDiagnostic[]` is captured and exposable via API instead of silently dropped. +- `ProjectRuntime` construction becomes async (static factory pattern), matching Pi's own SDK ergonomics. + +## Why This Is a Separate Commit + +Keeping this as its own commit on top of the split refactor: + +1. **Independent rollback.** If the snapshot semantics on `ResourceLoader` (see Risks) cause an unforeseen issue in production, we can revert this commit cleanly without losing the `ProjectRuntime` / `ProjectSession` separation. +2. **Bisectable.** Two changes with two different blast radii deserve two commits. +3. **Reviewable.** Reviewers can evaluate "should we adopt Pi's services bundle?" separately from "should we split per-session concerns out of `AgentRuntime`?" +4. **Self-contained scope.** This refactor doesn't touch `routes.ts` or `ProjectSession` — only `ProjectRuntime`, `runtimeRegistry.ts`, `server.ts` startup, and tests' construction calls. + +## Prerequisite State (After the Split) + +The split refactor leaves us with: + +``` +ProjectRuntime ← project-level + • authStorage, modelRegistry (held individually, shared from registry) + • agentDir, projectDir, sessionsDir + • agentsFile, systemPrompt + • extension/skill/prompt/theme paths + flags + • Map + • makeResourceLoader() ← per-session, expensive + • createNewSession() / getSession() / listSessions() + • diagnostics: silently dropped + │ + ▼ for each session: + createAgentSession({ ← lower-level Pi API + authStorage, modelRegistry, + sessionManager, + resourceLoader: await makeResourceLoader(), // fresh every call + }) +``` + +Specifically, today's per-session creation calls `makeResourceLoader()`, which: + +```typescript +private async makeResourceLoader(): Promise { + const settingsManager = SettingsManager.create(this.projectDir, this.agentDir); + const loader = new DefaultResourceLoader({ ... }); + await loader.reload(); // ← walks fs, parses extensions, loads skills/themes + return loader; +} +``` + +For a project with N sessions, `loader.reload()` runs N times. That's the inefficiency this refactor eliminates. + +## Target State + +``` +ProjectRuntime ← project-level + • services: AgentSessionServices ← bundle (cwd, agentDir, authStorage, + • settingsManager, modelRegistry, + • resourceLoader, diagnostics) + • credentials (still passed in from registry) + • projectDir, sessionsDir (still held — services has cwd, but + • sessionsDir is agent-server-specific + • and not in services) + • model defaults (provider/id/thinking) + • Map + • static create(config) → Promise ← async factory + • createNewSession() / getSession() / listSessions() ← unchanged signatures + • reload() → Promise ← NEW: explicit ResourceLoader refresh + • diagnostics → readonly[] ← NEW: accessor + │ + ▼ for each session: + createAgentSessionFromServices({ ← higher-level Pi API + services: this.services, + sessionManager, + ...modelDefaults, + }) +``` + +## Pros & Cons (Recap) + +### Pros + +1. **One `ResourceLoader.reload()` per project, not per session.** For a 10-session project, eliminates 9 redundant filesystem walks, extension parses, and theme loads. +2. **One `SettingsManager` per project.** Settings don't change per-session. +3. **Diagnostics get a real home.** Pi expects callers to surface `AgentSessionRuntimeDiagnostic[]`. Today we drop them. Now we hold them and can expose them via API later. +4. **Cleaner session creation.** `createAgentSessionFromServices({ services, sessionManager, ...defaults })` reads better than the current hand-rolled options object. +5. **Pi vocabulary alignment.** Same types/names appear in agent-server and Pi's docs/source. Easier onboarding. +6. **Future-proof.** New cwd-bound services Pi adds to the bundle come for free. +7. **Extension-provided custom providers register once per project.** Currently re-registered per session. + +### Cons + +1. **Behavior change: resources snapshot at project startup.** Today, every `createNewSession()` / `getSession()` triggers a fresh `reload()` — new files on disk are picked up. With shared services, sessions created later see the project-startup snapshot until something calls `reload()`. + - For builder-container deployment (resources baked into image): no impact. + - For dev workflows (skill files added during a session): mitigated by `await projectRuntime.reload()` API. +2. **`ProjectRuntime` construction becomes async.** Ripples to `AgentRuntimeRegistry` (also becomes async-constructed) and to anywhere that creates a registry at startup. +3. **Tighter coupling to `AgentSessionServices` shape.** A breaking change in Pi's bundle interface affects us. Risk is real but small — it's been stable. +4. **One more concept for contributors.** "Why services and not individual fields?" Worth a doc paragraph. + +## File-by-File Plan + +Assumes the split refactor has landed. Files referenced are post-split names. + +### 1. `src/projectRuntime.ts` + +Replace individual service fields with `services`. Move agentsFile/systemPrompt reading into the static factory. Remove `makeResourceLoader()`. + +```typescript +import { + type AgentSessionServices, + type AgentSessionRuntimeDiagnostic, + type AuthStorage, + type ModelRegistry, + createAgentSessionServices, + createAgentSessionFromServices, + SessionManager, + type SessionInfo, + // ... other imports unchanged +} from "@earendil-works/pi-coding-agent"; + +export type ProjectRuntimeConfig = { + projectDir: string; + sessionsDir: string; + agentDir?: string; + credentials: AgentCredentialsService; + authStorage?: AuthStorage; // shared from registry, optional input + modelRegistry?: ModelRegistry; // shared from registry, optional input + anthropicApiKey?: string; + configureModelRegistry?: (modelRegistry: ModelRegistry) => void; + defaultModelProvider?: string; + defaultModelId?: string; + defaultThinkingLevel?: ThinkingLevel; + modelThinkingDefaults?: Record; + extensionPaths?: string[]; + skillPaths?: string[]; + promptTemplatePaths?: string[]; + themePaths?: string[]; + extensionFactories?: ExtensionFactory[]; + noExtensions?: boolean; + noSkills?: boolean; + noPromptTemplates?: boolean; + noThemes?: boolean; + agentsFile?: string; + logger?: Pick; +}; + +export class ProjectRuntime { + readonly credentials: AgentCredentialsService; + readonly services: AgentSessionServices; + + private readonly projectDir: string; + private readonly sessionsDir: string; + private readonly defaultModelProvider: string | undefined; + private readonly defaultModelId: string | undefined; + private readonly defaultThinkingLevel: ThinkingLevel | undefined; + private readonly logger: Pick; + private readonly sessions = new Map(); + + /** + * Async factory. Creates the AgentSessionServices bundle (which runs + * resourceLoader.reload() once and registers extension-provided custom + * model providers into the shared modelRegistry) and constructs the + * runtime around it. + */ + static async create(config: ProjectRuntimeConfig): Promise { + const projectDir = resolve(config.projectDir); + const sessionsDir = resolve(config.sessionsDir); + const agentDir = config.agentDir ? resolve(config.agentDir) : getAgentDir(); + const logger = config.logger ?? console; + + mkdirSync(sessionsDir, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + + // Read pinned system prompt if specified, suppress ancestor walk if so. + const { systemPrompt, agentsFilePath } = readPinnedSystemPrompt(config, projectDir, logger); + + // Inject runtime API key into shared AuthStorage (caller-provided). + if (config.anthropicApiKey && config.authStorage) { + config.authStorage.setRuntimeApiKey("anthropic", config.anthropicApiKey); + logger.log("[agent] runtime ANTHROPIC_API_KEY injected"); + } + + // Build the services bundle. Pi creates ResourceLoader + SettingsManager + // here, runs reload(), and registers extension-provided custom providers + // into the (shared) modelRegistry. + const services = await createAgentSessionServices({ + cwd: projectDir, + agentDir, + authStorage: config.authStorage, + modelRegistry: config.modelRegistry, + resourceLoaderOptions: { + additionalExtensionPaths: config.extensionPaths, + additionalSkillPaths: config.skillPaths, + additionalPromptTemplatePaths: config.promptTemplatePaths, + additionalThemePaths: config.themePaths, + extensionFactories: config.extensionFactories, + noExtensions: config.noExtensions, + noSkills: config.noSkills, + noPromptTemplates: config.noPromptTemplates, + noThemes: config.noThemes, + // When systemPrompt is pinned, suppress Pi's ancestor AGENTS.md walk. + noContextFiles: systemPrompt !== undefined, + systemPrompt, + }, + }); + + if (agentsFilePath) { + logger.log( + `[agent] system prompt loaded from ${agentsFilePath} (${systemPrompt!.length} chars)`, + ); + } + + // Apply caller's modelRegistry hook only if registry isn't shared. + // (Shared registries are configured once at the registry level.) + if (!config.modelRegistry) { + config.configureModelRegistry?.(services.modelRegistry); + } + + // Surface diagnostics from services creation. + for (const diag of services.diagnostics) { + const log = diag.type === "error" ? logger.error : logger.log; + log.call(logger, `[agent] ${diag.type}: ${diag.message}`); + } + + // Validate the configured default model resolves & has auth. + if (config.defaultModelProvider && config.defaultModelId) { + const model = services.modelRegistry.find( + config.defaultModelProvider, + config.defaultModelId, + ); + if (!model) { + logger.error( + `[agent] default model not found: ${config.defaultModelProvider}/${config.defaultModelId}`, + ); + } else if (!services.modelRegistry.hasConfiguredAuth(model)) { + logger.error( + `[agent] auth is not configured for default model ${model.provider}/${model.id}`, + ); + } else { + logger.log(`[agent] default model: ${model.provider}/${model.id}`); + } + } + + return new ProjectRuntime( + { + projectDir, + sessionsDir, + defaultModelProvider: config.defaultModelProvider, + defaultModelId: config.defaultModelId, + defaultThinkingLevel: config.defaultThinkingLevel, + credentials: config.credentials, + logger, + }, + services, + ); + } + + private constructor( + fields: { + projectDir: string; + sessionsDir: string; + defaultModelProvider: string | undefined; + defaultModelId: string | undefined; + defaultThinkingLevel: ThinkingLevel | undefined; + credentials: AgentCredentialsService; + logger: Pick; + }, + services: AgentSessionServices, + ) { + this.projectDir = fields.projectDir; + this.sessionsDir = fields.sessionsDir; + this.defaultModelProvider = fields.defaultModelProvider; + this.defaultModelId = fields.defaultModelId; + this.defaultThinkingLevel = fields.defaultThinkingLevel; + this.credentials = fields.credentials; + this.logger = fields.logger; + this.services = services; + } + + // ── Session collection management ───────────────────────────────── + + async createNewSession(): Promise { + const { session } = await createAgentSessionFromServices({ + services: this.services, + sessionManager: SessionManager.create(this.projectDir, this.sessionsDir), + ...this.sessionModelDefaults(), + }); + return this.adopt(session); + } + + async getSession(id: string): Promise { + const existing = this.sessions.get(id); + if (existing) return existing; + + const sessions = await SessionManager.list(this.projectDir, this.sessionsDir); + const info = sessions.find((s) => s.id === id); + if (!info) return null; + + const { session } = await createAgentSessionFromServices({ + services: this.services, + sessionManager: SessionManager.open(info.path), + ...this.sessionModelDefaults(), + }); + return this.adopt(session); + } + + async listSessions(): Promise { + // unchanged from post-split version; only the dependency-bundle source changed + } + + // ── New: explicit refresh hook ──────────────────────────────────── + + /** + * Reload project resources (skills, extensions, prompts, themes, etc.) + * from disk. Existing live sessions keep their already-bound extensions; + * only sessions created after this call see the new resources. + * + * If you need existing sessions to pick up new extensions too, you'll + * have to dispose+recreate them — out of scope today. + */ + async reload(): Promise { + await this.services.resourceLoader.reload(); + } + + // ── New: diagnostics accessor ───────────────────────────────────── + + /** + * Non-fatal issues collected during services creation: extension load + * errors, unknown extension flags, custom provider registration failures. + * Surface these to operators / API consumers as appropriate. + */ + diagnostics(): readonly AgentSessionRuntimeDiagnostic[] { + return this.services.diagnostics; + } + + // ── Private helpers ─────────────────────────────────────────────── + + private adopt(session: AgentSession): ProjectSession { + const ps = new ProjectSession(session, { + credentials: this.credentials, + modelRegistry: this.services.modelRegistry, + logger: this.logger, + }); + this.sessions.set(ps.sessionId, ps); + return ps; + } + + private sessionModelDefaults(): { model?: SessionModel; thinkingLevel?: ThinkingLevel } { + const defaults: { model?: SessionModel; thinkingLevel?: ThinkingLevel } = {}; + if (this.defaultModelProvider && this.defaultModelId) { + const model = this.services.modelRegistry.find( + this.defaultModelProvider, + this.defaultModelId, + ) as SessionModel | undefined; + if (model) { + defaults.model = model; + const thinkingLevel = this.credentials.defaultThinkingForModel(model); + if (thinkingLevel) defaults.thinkingLevel = thinkingLevel; + } + } + if (!defaults.thinkingLevel && this.defaultThinkingLevel) { + defaults.thinkingLevel = this.defaultThinkingLevel; + } + return defaults; + } +} + +/** + * Read pinned system prompt file if specified. Returns the prompt content + * and resolved path. Throws on read failure (consistent with current behavior). + */ +function readPinnedSystemPrompt( + config: ProjectRuntimeConfig, + projectDir: string, + logger: Pick, +): { systemPrompt: string | undefined; agentsFilePath: string | undefined } { + if (!config.agentsFile) return { systemPrompt: undefined, agentsFilePath: undefined }; + const path = isAbsolute(config.agentsFile) + ? config.agentsFile + : resolve(projectDir, config.agentsFile); + try { + const systemPrompt = readFileSync(path, "utf8"); + return { systemPrompt, agentsFilePath: path }; + } catch (err) { + logger.error(`[agent] failed to read agentsFile ${path}: ${String(err)}`); + throw err; + } +} +``` + +**Removed**: +- `makeResourceLoader()` private method (services holds the loader). +- `agentDir` private field (services has it). +- Direct `authStorage` / `modelRegistry` private fields (services has them; expose via `services.authStorage` etc. if needed elsewhere). +- Per-session `SettingsManager.create()` call. +- Inline systemPrompt logic in constructor. + +**Added**: +- Static `create(config)` async factory. +- `services: AgentSessionServices` readonly field. +- `reload()` method. +- `diagnostics()` accessor. +- Top-level `readPinnedSystemPrompt()` helper. + +### 2. `src/runtimeRegistry.ts` + +`forProject` becomes async. The registry itself becomes constructed via static async factory for symmetry — but the `defaultRuntime` story is the load-bearing change. + +Today: +```typescript +class AgentRuntimeRegistry { + readonly defaultRuntime: AgentRuntime; + + constructor(config: AgentRuntimeRegistryConfig) { + // ... sync setup ... + this.defaultRuntime = this.createRuntime({ id: "default", projectDir }); + } +} +``` + +After: +```typescript +class AgentRuntimeRegistry { + readonly defaultRuntime: ProjectRuntime; + + static async create(config: AgentRuntimeRegistryConfig): Promise { + const registry = new AgentRuntimeRegistry(config); + registry.defaultRuntime = await registry.createRuntime({ + id: "default", + projectDir: registry.config.projectDir, + }); + return registry; + } + + private constructor(config: AgentRuntimeRegistryConfig) { + // sync field assignment only — no runtime creation + } + + async forProject(context: ProjectRuntimeContext): Promise { + // ... existing existence check ... + const runtime = await this.createRuntime({ ...context, projectDir }); + this.runtimes.set(context.id, { projectDir, runtime }); + return runtime; + } + + private async createRuntime(context: ProjectRuntimeContext): Promise { + // ... existing config assembly ... + return ProjectRuntime.create({ + ...this.config, + projectDir, + sessionsDir, + credentials: this.credentials, + authStorage: this.authStorage, + modelRegistry: this.modelRegistry, + configureModelRegistry: undefined, + extensionPaths, + agentsFile, + }); + } +} +``` + +The `defaultRuntime` field is initialized only inside `create()`, after the async runtime is built. To satisfy `readonly` + TypeScript, we either: + +- Use `definite assignment assertion` (`defaultRuntime!: ProjectRuntime`) and assign inside `create()` after instantiation, or +- Pass it into a private constructor that takes both config and the pre-built runtime. + +The second is cleaner. Constructor signature becomes `(config, defaultRuntime)`. + +### 3. `src/server.ts` (or wherever `AgentRuntimeRegistry` is instantiated) + +Replace `new AgentRuntimeRegistry(config)` with `await AgentRuntimeRegistry.create(config)`. Already top-level-await-friendly in modern Node; if startup is in a function, that function becomes async (it almost certainly already is). + +### 4. `src/index.ts` + +Re-export newly-public types: + +```typescript +export type { + AgentSessionServices, + AgentSessionRuntimeDiagnostic, +} from "@earendil-works/pi-coding-agent"; +// (Re-exporting these from agent-server's surface is convenient for +// consumers that want to inspect ProjectRuntime.services / .diagnostics() +// without separately importing pi-coding-agent.) +``` + +`ProjectRuntime` and `ProjectRuntimeConfig` exports are unchanged from the post-split state. + +### 5. `test/server.test.ts` + +```typescript +// Before +const registry = new AgentRuntimeRegistry({ ... }); + +// After +const registry = await AgentRuntimeRegistry.create({ ... }); +``` + +`AgentRuntime` direct-construction sites in tests (`new AgentRuntime({...})`) become `await ProjectRuntime.create({...})`. + +Test bodies that drive the system through `fetch` against the real HTTP routes don't change at all — the HTTP surface is identical. + +### 6. `test/projectSession.test.ts` (if it constructs `ProjectRuntime` directly) + +Same `await ProjectRuntime.create(...)` change. + +## Step-by-Step Implementation Order + +Each step keeps `npm run check` and the existing test suite green. + +1. **Add a `getServices(): AgentSessionServices` shim to `ProjectRuntime`.** + Without changing structure, build a services object on demand from the existing fields (so we can add usages incrementally). This is throwaway code; deleted in step 4. + +2. **Switch session creation to `createAgentSessionFromServices`.** + In `createNewSession()` and `getSession()`, replace the `createAgentSession({ authStorage, modelRegistry, sessionManager, resourceLoader })` call with `createAgentSessionFromServices({ services: this.getServices(), sessionManager, ...defaults })`. Verify nothing changes behaviorally — `createAgentSessionFromServices` is a thin wrapper that does exactly the equivalent call. Tests should pass. + +3. **Introduce `ProjectRuntime.create()` async factory.** + Add the static factory that calls `createAgentSessionServices()`. Convert one usage site (e.g., the registry's default runtime) to use it. Keep the sync constructor temporarily for backward compatibility with other callers. Tests should pass. + +4. **Convert `AgentRuntimeRegistry.forProject()` to async + add `AgentRuntimeRegistry.create()`.** + Update `server.test.ts` and `server.ts` startup. Remove the temporary sync constructor on `ProjectRuntime`. Remove the `getServices()` shim — `services` is now a real readonly field assigned in the factory. Tests should pass. + +5. **Remove `makeResourceLoader()` and per-session `SettingsManager` construction.** + At this point sessions get their resources from `services`, so the per-session helper is dead code. Tests should pass. + +6. **Add `reload()` and `diagnostics()` public API on `ProjectRuntime`.** + No callers yet — these are net-new surface for future use. Add a unit test for `reload()` (calls `services.resourceLoader.reload()` exactly once). + +7. **Surface diagnostics in startup logs.** + When `ProjectRuntime.create()` finishes, log warnings/errors from `services.diagnostics`. (Already shown in the code sketch above.) + +8. **Update docs.** + - This file: mark Status as "Landed" with the relevant commit SHA. + - `docs/architecture/pi-component-responsibilities.md`: update the agent-server mapping table to show `services: AgentSessionServices` ownership. + - `docs/architecture/builder-container-architecture.md`: update the inner diagram if it references individual auth/registry holdings. + +Steps 1–2 can land as a single "no-op refactor" commit. Step 3 is "introduce factory." Step 4 is the API breaking change. Step 5 is cleanup. Steps 6–8 are additive. + +Realistically this might collapse into 2–3 commits in practice, but the granularity is here if we want it. + +## Tests + +### Unit tests for `ProjectRuntime` (new or updated) + +- `ProjectRuntime.create()` resolves to a runtime with a populated `services` bundle. +- `services.resourceLoader` is the same instance across two `createNewSession()` calls (proves we're not recreating per session). +- `services.settingsManager` is the same instance across two `getSession()` calls. +- `diagnostics()` returns `services.diagnostics` (identity, not copy). +- `reload()` invokes `services.resourceLoader.reload()` exactly once and is idempotent. +- `ProjectRuntime.create()` propagates the read failure from `agentsFile` (if invalid path). + +### Integration regression in `test/server.test.ts` + +Add: a project with an extension that registers a custom model provider should register it **once** at project startup, not N times for N sessions. (Today's behavior re-registers per-session; Pi's `registerProvider` is idempotent so this is currently silent waste — the regression test ensures we don't accidentally re-introduce per-session registration after the refactor.) + +## Risks & Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|-----------| +| Snapshot semantics surprise: skill files added after project startup don't appear in new sessions | Medium | Low (single-admin builder use case) | Document explicitly in `ProjectRuntime` JSDoc; expose `reload()` API; consider auto-reload via fs watcher as a follow-up if it becomes a friction point | +| Async constructor cascade missed at some call site | Low | Low | TypeScript catches it: a `Promise` is structurally distinct from `ProjectRuntime`; if a caller forgets to await, type check fails | +| `services.diagnostics` populated with errors that should have been thrown | Low | Medium | At step 7, treat `error`-severity diagnostics as startup failures (throw) rather than just logs, matching Pi's existing patterns in `runtime.ts` (e.g., default-model auth check today logs error but doesn't throw — keep that consistent) | +| Pi's `AgentSessionServices` shape changes in a future Pi version | Low | Medium | Pin the Pi version; update intentionally on bumps; the bundle is small enough that breaks are easy to fix | +| `configureModelRegistry` hook semantics change (only fires when registry isn't shared) | Low | Low | Already only-fires-when-not-shared today (line 287 of `runtime.ts`). Behavior is preserved. | +| Memory profile: each project now permanently holds a `ResourceLoader` (vs today's per-session, GC'd between sessions) | Low | Low | Inverse trade-off: fewer reloads but longer-lived references. Net is roughly neutral; benchmark only if it becomes a concern with many idle projects. | + +## Rollback Plan + +If this refactor needs to be reverted post-merge: + +1. Revert this commit. +2. `ProjectRuntime` returns to holding individual `authStorage` / `modelRegistry` / `agentDir` fields and a `makeResourceLoader()` helper. +3. `AgentRuntimeRegistry.create()` becomes the sync constructor again. +4. `server.ts` startup drops the `await`. +5. Tests: revert the `await ... .create()` changes. + +The split refactor (`ProjectRuntime` / `ProjectSession` separation, routes' two-step lookup) is **not** affected by the rollback. `routes.ts`, `projectSession.ts`, and `extensionUi.ts` stay as-is. This is the whole point of keeping the two refactors as separate commits. + +## Out of Scope + +1. **File-watcher-driven auto-reload.** Useful for dev, but adds a runtime cost and a failure mode (dropped events, churn). Defer until there's a concrete use case. +2. **Disposing live sessions on `reload()` to pick up new extensions.** Sessions outlive resource refreshes intentionally — extensions that have already loaded into a session shouldn't be yanked out. If we want this, do it as an explicit per-session API. +3. **Exposing `GET /v1/projects/{id}/diagnostics` HTTP endpoint.** Easy follow-up once `diagnostics()` exists, but separate concern. Decide based on whether appx wants to surface them in its UI. +4. **Per-project (rather than registry-wide) `AuthStorage` or `ModelRegistry`.** Would lose the shared-credentials property the builder-container architecture relies on. Don't do this. +5. **Importing `AgentSessionRuntime`.** Still rejected for the reasons in `docs/architecture/adapter-pattern-explained.md`. Using `AgentSessionServices` is fully compatible with continuing to use `AgentSession` directly via `createAgentSessionFromServices`. + +## Done When + +1. `ProjectRuntime.services: AgentSessionServices` exists and is the source of truth for `authStorage`, `modelRegistry`, `settingsManager`, `resourceLoader`, `cwd`, `agentDir`. +2. `makeResourceLoader()` and per-session `SettingsManager.create()` are deleted from the codebase. +3. Session creation uses `createAgentSessionFromServices` everywhere. +4. `ProjectRuntime.create(config)` is the only construction path; no public sync constructor. +5. `AgentRuntimeRegistry.create(config)` is async; `forProject()` is async. +6. `ProjectRuntime.reload()` and `ProjectRuntime.diagnostics()` exist and are tested. +7. Diagnostics from project startup are logged. +8. `npm run check` is green; all tests (existing + new) pass. +9. Migration plan checked into the docs/architecture/ folder is updated to "Landed." diff --git a/openapi.json b/openapi.json index e2059d2..7d9c0a9 100644 --- a/openapi.json +++ b/openapi.json @@ -3,96 +3,452 @@ "info": { "title": "Appx Agent Server", "version": "0.1.0", - "description": "Pi-SDK-based agent orchestration. Single-tenant per process; one instance per Appx app." + "description": "Pi-SDK-based agent orchestration. Shared auth/model state with explicit, persisted project-scoped session runtimes." }, "components": { "schemas": { - "SessionRow": { + "ThinkingLevel": { + "oneOf": [ + { + "const": "off" + }, + { + "const": "minimal" + }, + { + "const": "low" + }, + { + "const": "medium" + }, + { + "const": "high" + }, + { + "const": "xhigh" + } + ], + "description": "Thinking/reasoning level for models that support it.\nNote: \"xhigh\" is only supported by selected model families. Use model thinking-level metadata\nfrom" + }, + "AgentModelRow": { "type": "object", "properties": { + "provider": { + "type": "string" + }, "id": { - "type": "string", - "example": "01J9Z..." + "type": "string" }, - "createdAt": { + "name": { + "type": "string" + }, + "api": { + "type": "string" + }, + "reasoning": { + "type": "boolean" + }, + "available": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "text", + "image" + ] + } + }, + "contextWindow": { + "type": "integer", + "minimum": 0 + }, + "maxTokens": { + "type": "integer", + "minimum": 0 + }, + "defaultThinkingLevel": { + "$ref": "#/components/schemas/ThinkingLevel" + } + }, + "required": [ + "provider", + "id", + "name", + "api", + "reasoning", + "available", + "input", + "contextWindow", + "maxTokens" + ] + }, + "ListModelsResponse": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentModelRow" + } + } + }, + "required": [ + "models" + ] + }, + "AuthProviderRow": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "name": { + "type": "string" + }, + "configured": { + "type": "boolean" + }, + "credentialType": { "type": "string", - "description": "ISO-8601 UTC timestamp", - "example": "2026-05-17T10:00:00.000Z" + "enum": [ + "api_key", + "oauth" + ] }, - "firstMessage": { + "source": { "type": "string", - "description": "First user message; empty for never-prompted sessions." + "enum": [ + "stored", + "runtime", + "environment", + "fallback", + "models_json_key", + "models_json_command" + ] }, - "messageCount": { + "label": { + "type": "string" + }, + "supportsApiKey": { + "type": "boolean" + }, + "supportsSubscription": { + "type": "boolean" + }, + "modelCount": { + "type": "integer", + "minimum": 0 + }, + "availableModelCount": { "type": "integer", "minimum": 0 } }, "required": [ - "id", - "createdAt", - "firstMessage", - "messageCount" + "provider", + "name", + "configured", + "supportsApiKey", + "supportsSubscription", + "modelCount", + "availableModelCount" ] }, - "ListSessionsResponse": { + "ListAuthProvidersResponse": { "type": "object", "properties": { - "sessions": { + "providers": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionRow" + "$ref": "#/components/schemas/AuthProviderRow" } } }, "required": [ - "sessions" + "providers" ] }, - "CreateSessionResponse": { + "OkResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "ok" + ] + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, + "SetProviderApiKeyRequest": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "key" + ] + }, + "OAuthFlowState": { "type": "object", "properties": { "id": { "type": "string" }, - "createdAt": { + "provider": { + "type": "string" + }, + "providerName": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "starting", + "prompt", + "auth", + "waiting", + "complete", + "error", + "cancelled" + ] + }, + "authUrl": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "prompt": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "allowEmpty": { + "type": "boolean" + } + }, + "required": [ + "message" + ] + }, + "progress": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "type": "string" + }, + "expiresAt": { "type": "string" } }, "required": [ "id", - "createdAt" + "provider", + "providerName", + "status", + "progress", + "expiresAt" ] }, - "SessionMessagesResponse": { + "ContinueOAuthFlowRequest": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "CustomProviderModel": { "type": "object", "properties": { "id": { + "type": "string", + "minLength": 1 + }, + "name": { "type": "string" }, - "messages": { + "api": { + "type": "string", + "enum": [ + "openai-completions", + "openai-responses", + "anthropic-messages" + ] + }, + "reasoning": { + "type": "boolean" + }, + "thinkingLevelMap": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + }, + { + "type": "null" + } + ] + } + }, + "input": { "type": "array", - "items": {}, - "description": "Pi-shaped message objects (role + content array). Opaque here." + "items": { + "type": "string", + "enum": [ + "text", + "image" + ] + } + }, + "contextWindow": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "maxTokens": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "compat": { + "type": "object", + "additionalProperties": {} } }, "required": [ - "id", - "messages" + "id" ] }, - "ErrorResponse": { + "CustomProviderRow": { "type": "object", "properties": { - "error": { + "provider": { + "type": "string" + }, + "name": { + "type": "string" + }, + "baseUrl": { + "type": "string" + }, + "api": { + "type": "string", + "enum": [ + "openai-completions", + "openai-responses", + "anthropic-messages" + ] + }, + "apiKeyConfigured": { + "type": "boolean" + }, + "modelCount": { + "type": "integer", + "minimum": 0 + }, + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomProviderModel" + } + } + }, + "required": [ + "provider", + "apiKeyConfigured", + "modelCount", + "models" + ] + }, + "ListCustomProvidersResponse": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomProviderRow" + } + } + }, + "required": [ + "providers" + ] + }, + "UpsertCustomProviderRequest": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z0-9_.:-]+$" + }, + "name": { + "type": "string" + }, + "baseUrl": { + "type": "string", + "format": "uri" + }, + "api": { + "type": "string", + "enum": [ + "openai-completions", + "openai-responses", + "anthropic-messages" + ] + }, + "apiKey": { "type": "string" + }, + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomProviderModel" + }, + "minItems": 1 } }, "required": [ - "error" + "provider", + "baseUrl", + "api", + "models" ] }, - "OkResponse": { + "HealthResponse": { "type": "object", "properties": { "ok": { @@ -100,93 +456,3008 @@ "enum": [ true ] + }, + "service": { + "type": "string", + "enum": [ + "agent-server" + ] + }, + "time": { + "type": "string" + }, + "channels": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Map of SSE channel name → current subscriber count." } }, "required": [ - "ok" + "ok", + "service", + "time", + "channels" ] }, - "PromptRequest": { + "ProjectInfo": { "type": "object", "properties": { - "text": { + "id": { + "type": "string", + "description": "Immutable slug; registry key, route param, and directory name.", + "example": "my-cool-app" + }, + "name": { + "type": "string", + "example": "My Cool App" + }, + "projectDir": { + "type": "string", + "description": "Absolute working directory under WORKSPACE_DIR.", + "example": "/workspace/my-cool-app" + }, + "createdAt": { + "type": "string", + "description": "ISO-8601 UTC timestamp", + "example": "2026-06-03T10:00:00.000Z" + } + }, + "required": [ + "id", + "name", + "projectDir", + "createdAt" + ] + }, + "CreateProjectRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-facing project name. Slugified into the immutable id and directory name.", + "example": "My Cool App" + } + }, + "required": [ + "name" + ] + }, + "ListProjectsResponse": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectInfo" + } + } + }, + "required": [ + "projects" + ] + }, + "SessionRow": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "01J9Z..." + }, + "createdAt": { + "type": "string", + "description": "ISO-8601 UTC timestamp", + "example": "2026-05-17T10:00:00.000Z" + }, + "firstMessage": { + "type": "string", + "description": "First user message; empty for never-prompted sessions." + }, + "messageCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "id", + "createdAt", + "firstMessage", + "messageCount" + ] + }, + "ListSessionsResponse": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionRow" + } + } + }, + "required": [ + "sessions" + ] + }, + "CreateSessionResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "createdAt" + ] + }, + "SessionModelSettingsResponse": { + "type": "object", + "properties": { + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/AgentModelRow" + }, + { + "type": "null" + }, + { + "type": "null" + } + ] + }, + "thinkingLevel": { + "$ref": "#/components/schemas/ThinkingLevel" + }, + "availableThinkingLevels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThinkingLevel" + } + }, + "supportsThinking": { + "type": "boolean" + }, + "isStreaming": { + "type": "boolean" + } + }, + "required": [ + "model", + "thinkingLevel", + "availableThinkingLevels", + "supportsThinking", + "isStreaming" + ] + }, + "PatchSessionSettingsRequest": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "modelId": { + "type": "string", + "minLength": 1 + }, + "thinkingLevel": { + "$ref": "#/components/schemas/ThinkingLevel" + } + } + }, + "SessionMessagesResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentMessage" + }, + "description": "Pi-shaped message objects. Forwarded as-is at runtime; published as AgentMessage[] in the contract." + } + }, + "required": [ + "id", + "messages" + ] + }, + "PendingExtensionUiRequestsResponse": { + "type": "object", + "properties": { + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionUiRequest" + }, + "description": "Pending extension UI request events. Forwarded as-is at runtime; published as ExtensionUiRequest[] in the contract." + } + }, + "required": [ + "requests" + ] + }, + "ExtensionUiResponseRequest": { + "anyOf": [ + { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + { + "type": "object", + "properties": { + "confirmed": { + "type": "boolean" + } + }, + "required": [ + "confirmed" + ] + }, + { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "cancelled" + ] + } + ] + }, + "PromptRequest": { + "type": "object", + "properties": { + "text": { "type": "string", "minLength": 1, "example": "find me events this weekend" } }, - "required": [ - "text" - ] - }, - "HealthResponse": { - "type": "object", - "properties": { - "ok": { - "type": "boolean", - "enum": [ - true - ] + "required": [ + "text" + ] + }, + "WireEvent": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "select" + }, + "title": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title", + "options" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "confirm" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "input" + }, + "title": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "editor" + }, + "title": { + "type": "string" + }, + "prefill": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "notify" + }, + "message": { + "type": "string" + }, + "notifyType": { + "oneOf": [ + { + "const": "info" + }, + { + "const": "warning" + }, + { + "const": "error" + } + ] + } + }, + "required": [ + "type", + "id", + "method", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setStatus" + }, + "statusKey": { + "type": "string" + }, + "statusText": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "statusKey" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setWidget" + }, + "widgetKey": { + "type": "string" + }, + "widgetLines": { + "type": "array", + "items": { + "type": "string" + } + }, + "widgetPlacement": { + "oneOf": [ + { + "const": "aboveEditor" + }, + { + "const": "belowEditor" + } + ] + } + }, + "required": [ + "type", + "id", + "method", + "widgetKey" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setTitle" + }, + "title": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "set_editor_text" + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "text" + ] + }, + { + "$ref": "#/components/schemas/ExtensionErrorEvent" + }, + { + "type": "object", + "properties": { + "type": { + "const": "agent_start" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "turn_start" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "turn_end" + }, + "message": { + "$ref": "#/components/schemas/AgentMessage" + }, + "toolResults": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolResultMessageany_o1" + } + } + }, + "required": [ + "type", + "message", + "toolResults" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "message_start" + }, + "message": { + "$ref": "#/components/schemas/AgentMessage" + } + }, + "required": [ + "type", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "message_update" + }, + "message": { + "$ref": "#/components/schemas/AgentMessage" + }, + "assistantMessageEvent": { + "$ref": "#/components/schemas/AssistantMessageEvent" + } + }, + "required": [ + "type", + "message", + "assistantMessageEvent" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "message_end" + }, + "message": { + "$ref": "#/components/schemas/AgentMessage" + } + }, + "required": [ + "type", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "tool_execution_start" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "args": {} + }, + "required": [ + "type", + "toolCallId", + "toolName", + "args" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "tool_execution_update" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "args": {}, + "partialResult": {} + }, + "required": [ + "type", + "toolCallId", + "toolName", + "args", + "partialResult" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "tool_execution_end" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "result": {}, + "isError": { + "type": "boolean" + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "result", + "isError" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "agent_end" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentMessage" + } + }, + "willRetry": { + "type": "boolean" + } + }, + "required": [ + "type", + "messages", + "willRetry" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "queue_update" + }, + "steering": { + "type": "array", + "items": { + "type": "string" + } + }, + "followUp": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "steering", + "followUp" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "compaction_start" + }, + "reason": { + "oneOf": [ + { + "const": "manual" + }, + { + "const": "threshold" + }, + { + "const": "overflow" + } + ] + } + }, + "required": [ + "type", + "reason" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "session_info_changed" + }, + "name": { + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "thinking_level_changed" + }, + "level": { + "$ref": "#/components/schemas/ThinkingLevel" + } + }, + "required": [ + "type", + "level" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "compaction_end" + }, + "reason": { + "oneOf": [ + { + "const": "manual" + }, + { + "const": "threshold" + }, + { + "const": "overflow" + } + ] + }, + "result": { + "$ref": "#/components/schemas/CompactionResultunknown" + }, + "aborted": { + "type": "boolean" + }, + "willRetry": { + "type": "boolean" + }, + "errorMessage": { + "type": "string" + } + }, + "required": [ + "type", + "reason", + "aborted", + "willRetry" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "auto_retry_start" + }, + "attempt": { + "type": "number" + }, + "maxAttempts": { + "type": "number" + }, + "delayMs": { + "type": "number" + }, + "errorMessage": { + "type": "string" + } + }, + "required": [ + "type", + "attempt", + "maxAttempts", + "delayMs", + "errorMessage" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "auto_retry_end" + }, + "success": { + "type": "boolean" + }, + "attempt": { + "type": "number" + }, + "finalError": { + "type": "string" + } + }, + "required": [ + "type", + "success", + "attempt" + ] + } + ], + "description": "Every JSON event agent-server forwards on `GET …/sessions/{id}/events`." + }, + "ExtensionErrorEvent": { + "type": "object", + "properties": { + "type": { + "const": "extension_error" + }, + "extensionPath": { + "type": "string" + }, + "event": { + "type": "string", + "description": "The pi lifecycle event during which the error occurred (e.g. \"session_start\")." + }, + "error": { + "type": "string" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "extensionPath", + "error" + ], + "description": "Emitted when a pi extension handler throws; surfaced to the UI for visibility." + }, + "AgentMessage": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, + { + "$ref": "#/components/schemas/AssistantMessage" + }, + { + "$ref": "#/components/schemas/ToolResultMessageany" + }, + { + "$ref": "#/components/schemas/BashExecutionMessage" + }, + { + "$ref": "#/components/schemas/CustomMessageunknown" + }, + { + "$ref": "#/components/schemas/BranchSummaryMessage" + }, + { + "$ref": "#/components/schemas/CompactionSummaryMessage" + } + ], + "discriminator": { + "propertyName": "role", + "mapping": { + "user": "#/components/schemas/UserMessage", + "assistant": "#/components/schemas/AssistantMessage", + "toolResult": "#/components/schemas/ToolResultMessageany", + "bashExecution": "#/components/schemas/BashExecutionMessage", + "custom": "#/components/schemas/CustomMessageunknown", + "branchSummary": "#/components/schemas/BranchSummaryMessage", + "compactionSummary": "#/components/schemas/CompactionSummaryMessage" + } + }, + "description": "AgentMessage: Union of LLM messages + custom messages.\nThis abstraction allows apps to add custom message types while maintaining\ntype safety and compatibility with the base LLM messages." + }, + "UserMessage": { + "type": "object", + "properties": { + "role": { + "const": "user" + }, + "content": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ImageContent" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "image": "#/components/schemas/ImageContent" + } + } + } + } + ] + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "content", + "timestamp" + ] + }, + "TextContent": { + "type": "object", + "properties": { + "type": { + "const": "text" + }, + "text": { + "type": "string" + }, + "textSignature": { + "type": "string" + } + }, + "required": [ + "type", + "text" + ] + }, + "ImageContent": { + "type": "object", + "properties": { + "type": { + "const": "image" + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "type", + "data", + "mimeType" + ] + }, + "AssistantMessage": { + "type": "object", + "properties": { + "role": { + "const": "assistant" + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ThinkingContent" + }, + { + "$ref": "#/components/schemas/ToolCall" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "thinking": "#/components/schemas/ThinkingContent", + "toolCall": "#/components/schemas/ToolCall" + } + } + } + }, + "api": { + "$ref": "#/components/schemas/Api" + }, + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "responseModel": { + "type": "string" + }, + "responseId": { + "type": "string" + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantMessageDiagnostic" + } + }, + "usage": { + "$ref": "#/components/schemas/Usage" + }, + "stopReason": { + "$ref": "#/components/schemas/StopReason" + }, + "errorMessage": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "content", + "api", + "provider", + "model", + "usage", + "stopReason", + "timestamp" + ] + }, + "ThinkingContent": { + "type": "object", + "properties": { + "type": { + "const": "thinking" + }, + "thinking": { + "type": "string" + }, + "thinkingSignature": { + "type": "string" + }, + "redacted": { + "type": "boolean", + "description": "When true, the thinking content was redacted by safety filters. The opaque\nencrypted payload is stored in `thinkingSignature` so it can be passed back\nto the API for multi-turn continuity." + } + }, + "required": [ + "type", + "thinking" + ] + }, + "ToolCall": { + "type": "object", + "properties": { + "type": { + "const": "toolCall" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "arguments": { + "$ref": "#/components/schemas/Recordstringany" + }, + "thoughtSignature": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "name", + "arguments" + ] + }, + "Recordstringany": { + "type": "object", + "properties": {}, + "required": [], + "description": "Construct a type with a set of properties K of type T", + "additionalProperties": {} + }, + "Api": { + "type": "string" + }, + "AssistantMessageDiagnostic": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/DiagnosticErrorInfo" + }, + "details": { + "$ref": "#/components/schemas/Recordstringunknown" + } + }, + "required": [ + "type", + "timestamp" + ] + }, + "DiagnosticErrorInfo": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "type": "string" + }, + "code": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "message" + ] + }, + "Recordstringunknown": { + "type": "object", + "properties": {}, + "required": [], + "description": "Construct a type with a set of properties K of type T", + "additionalProperties": {} + }, + "Usage": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cacheRead": { + "type": "number" + }, + "cacheWrite": { + "type": "number" + }, + "totalTokens": { + "type": "number" + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cacheRead": { + "type": "number" + }, + "cacheWrite": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "input", + "output", + "cacheRead", + "cacheWrite", + "total" + ] + } + }, + "required": [ + "input", + "output", + "cacheRead", + "cacheWrite", + "totalTokens", + "cost" + ] + }, + "StopReason": { + "oneOf": [ + { + "const": "error" + }, + { + "const": "stop" + }, + { + "const": "length" + }, + { + "const": "toolUse" + }, + { + "const": "aborted" + } + ] + }, + "ToolResultMessageany": { + "type": "object", + "properties": { + "role": { + "const": "toolResult" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ImageContent" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "image": "#/components/schemas/ImageContent" + } + } + } + }, + "details": {}, + "isError": { + "type": "boolean" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "toolCallId", + "toolName", + "content", + "isError", + "timestamp" + ] + }, + "BashExecutionMessage": { + "type": "object", + "properties": { + "role": { + "const": "bashExecution" + }, + "command": { + "type": "string" + }, + "output": { + "type": "string" + }, + "exitCode": { + "type": "number" + }, + "cancelled": { + "type": "boolean" + }, + "truncated": { + "type": "boolean" + }, + "fullOutputPath": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "excludeFromContext": { + "type": "boolean" + } + }, + "required": [ + "role", + "command", + "output", + "cancelled", + "truncated", + "timestamp" + ] + }, + "CustomMessageunknown": { + "type": "object", + "properties": { + "role": { + "const": "custom" + }, + "customType": { + "type": "string" + }, + "content": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ImageContent" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "image": "#/components/schemas/ImageContent" + } + } + } + } + ] + }, + "display": { + "type": "boolean" + }, + "details": {}, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "customType", + "content", + "display", + "timestamp" + ] + }, + "BranchSummaryMessage": { + "type": "object", + "properties": { + "role": { + "const": "branchSummary" + }, + "summary": { + "type": "string" + }, + "fromId": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "summary", + "fromId", + "timestamp" + ] + }, + "CompactionSummaryMessage": { + "type": "object", + "properties": { + "role": { + "const": "compactionSummary" + }, + "summary": { + "type": "string" + }, + "tokensBefore": { + "type": "number" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "summary", + "tokensBefore", + "timestamp" + ] + }, + "ToolResultMessageany_o1": { + "type": "object", + "properties": { + "role": { + "const": "toolResult" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ImageContent" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "image": "#/components/schemas/ImageContent" + } + } + } + }, + "details": {}, + "isError": { + "type": "boolean" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "toolCallId", + "toolName", + "content", + "isError", + "timestamp" + ] + }, + "AssistantMessageEvent": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "start" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "text_start" + }, + "contentIndex": { + "type": "number" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "text_delta" + }, + "contentIndex": { + "type": "number" + }, + "delta": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "delta", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "text_end" + }, + "contentIndex": { + "type": "number" + }, + "content": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "content", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "thinking_start" + }, + "contentIndex": { + "type": "number" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "thinking_delta" + }, + "contentIndex": { + "type": "number" + }, + "delta": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "delta", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "thinking_end" + }, + "contentIndex": { + "type": "number" + }, + "content": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "content", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "toolcall_start" + }, + "contentIndex": { + "type": "number" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "toolcall_delta" + }, + "contentIndex": { + "type": "number" + }, + "delta": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "delta", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "toolcall_end" + }, + "contentIndex": { + "type": "number" + }, + "toolCall": { + "$ref": "#/components/schemas/ToolCall" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "toolCall", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "done" + }, + "reason": { + "oneOf": [ + { + "const": "stop" + }, + { + "const": "length" + }, + { + "const": "toolUse" + } + ] + }, + "message": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "reason", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "error" + }, + "reason": { + "oneOf": [ + { + "const": "error" + }, + { + "const": "aborted" + } + ] + }, + "error": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "reason", + "error" + ] + } + ], + "description": "Event protocol for AssistantMessageEventStream.\n\nStreams should emit `start` before partial updates, then terminate with either:\n- `done` carrying the final successful AssistantMessage, or\n- `error` carrying the final AssistantMessage with stopReason \"error\" or \"aborted\"\n and errorMessage." + }, + "CompactionResultunknown": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "firstKeptEntryId": { + "type": "string" + }, + "tokensBefore": { + "type": "number" + }, + "details": { + "description": "Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction)" + } + }, + "required": [ + "summary", + "firstKeptEntryId", + "tokensBefore" + ], + "description": "Result from compact() - SessionManager adds uuid/parentUuid when saving" + }, + "ExtensionUiRequest": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "select" + }, + "title": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title", + "options" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "confirm" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "input" + }, + "title": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "editor" + }, + "title": { + "type": "string" + }, + "prefill": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "notify" + }, + "message": { + "type": "string" + }, + "notifyType": { + "oneOf": [ + { + "const": "info" + }, + { + "const": "warning" + }, + { + "const": "error" + } + ] + } + }, + "required": [ + "type", + "id", + "method", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setStatus" + }, + "statusKey": { + "type": "string" + }, + "statusText": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "statusKey" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setWidget" + }, + "widgetKey": { + "type": "string" + }, + "widgetLines": { + "type": "array", + "items": { + "type": "string" + } + }, + "widgetPlacement": { + "oneOf": [ + { + "const": "aboveEditor" + }, + { + "const": "belowEditor" + } + ] + } + }, + "required": [ + "type", + "id", + "method", + "widgetKey" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setTitle" + }, + "title": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "set_editor_text" + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "text" + ] + } + ] + } + }, + "parameters": {} + }, + "paths": { + "/v1/sessions/models": { + "get": { + "operationId": "listModels", + "tags": [ + "models" + ], + "summary": "List models known to this runtime, including unavailable ones for diagnostics.", + "responses": { + "200": { + "description": "Known models.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListModelsResponse" + } + } + } + } + } + } + }, + "/v1/auth/providers": { + "get": { + "operationId": "listAuthProviders", + "tags": [ + "auth" + ], + "summary": "List non-secret provider auth status for the runtime.", + "responses": { + "200": { + "description": "Known providers and whether each has configured auth.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListAuthProvidersResponse" + } + } + } + } + } + } + }, + "/v1/auth/providers/{provider}/api-key": { + "put": { + "operationId": "setProviderApiKey", + "tags": [ + "auth" + ], + "summary": "Store an API key for a provider in Pi auth storage.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z0-9_.:-]+$" + }, + "required": true, + "name": "provider", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetProviderApiKeyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Credential stored.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Invalid provider or key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/auth/providers/{provider}": { + "delete": { + "operationId": "removeProviderCredential", + "tags": [ + "auth" + ], + "summary": "Remove a stored provider credential from Pi auth storage.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z0-9_.:-]+$" + }, + "required": true, + "name": "provider", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Credential removed if it existed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Invalid provider.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/auth/providers/{provider}/subscription/start": { + "post": { + "operationId": "startProviderSubscriptionLogin", + "tags": [ + "auth" + ], + "summary": "Start a Pi subscription OAuth login flow.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z0-9_.:-]+$" + }, + "required": true, + "name": "provider", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Current flow state. Continue if a prompt or pasted redirect is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthFlowState" + } + } + } + }, + "400": { + "description": "Provider does not support subscription auth.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/auth/subscription/{flowId}": { + "get": { + "operationId": "getProviderSubscriptionLogin", + "tags": [ + "auth" + ], + "summary": "Return subscription login flow state.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Current flow state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthFlowState" + } + } + } + }, + "404": { + "description": "Flow not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "operationId": "cancelProviderSubscriptionLogin", + "tags": [ + "auth" + ], + "summary": "Cancel a pending subscription login flow.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Cancelled flow state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthFlowState" + } + } + } + }, + "404": { + "description": "Flow not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/auth/subscription/{flowId}/continue": { + "post": { + "operationId": "continueProviderSubscriptionLogin", + "tags": [ + "auth" + ], + "summary": "Continue a subscription login flow with prompt input or pasted redirect URL.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "flowId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContinueOAuthFlowRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated flow state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthFlowState" + } + } + } + }, + "400": { + "description": "Invalid input.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Flow not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/custom/providers": { + "get": { + "operationId": "listCustomProviders", + "tags": [ + "models" + ], + "summary": "List custom models.json providers without secret values.", + "responses": { + "200": { + "description": "Custom providers.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListCustomProvidersResponse" + } + } + } + } + } + }, + "put": { + "operationId": "upsertCustomProvider", + "tags": [ + "models" + ], + "summary": "Create or update a custom Pi provider in models.json.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertCustomProviderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Custom provider saved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomProviderRow" + } + } + } + }, + "400": { + "description": "Invalid custom provider config.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/custom/providers/{provider}": { + "delete": { + "operationId": "removeCustomProvider", + "tags": [ + "models" + ], + "summary": "Remove a custom Pi provider from models.json.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z0-9_.:-]+$" + }, + "required": true, + "name": "provider", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Custom provider removed if it existed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Invalid provider.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/healthz": { + "get": { + "operationId": "healthCheck", + "tags": [ + "meta" + ], + "summary": "Liveness + diagnostic counters.", + "responses": { + "200": { + "description": "OK.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/v1/projects": { + "post": { + "operationId": "createProject", + "tags": [ + "projects" + ], + "summary": "Create a project, or return the existing one (idempotent on name).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The created or already-existing project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectInfo" + } + } + } + }, + "400": { + "description": "Name does not yield a valid project id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "get": { + "operationId": "listProjects", + "tags": [ + "projects" + ], + "summary": "List registered projects, newest first.", + "responses": { + "200": { + "description": "Registered projects.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListProjectsResponse" + } + } + } + } + } + } + }, + "/v1/projects/{id}": { + "get": { + "operationId": "getProject", + "tags": [ + "projects" + ], + "summary": "Get a single project's metadata.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Project metadata.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectInfo" + } + } + } + }, + "404": { + "description": "Unknown project id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteProject", + "tags": [ + "projects" + ], + "summary": "Remove a project: evict runtime, drop metadata, delete working dir + transcripts.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Project removed if it existed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "404": { + "description": "Unknown project id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/v1/projects/{projectId}/sessions": { + "get": { + "operationId": "listSessions", + "tags": [ + "sessions" + ], + "summary": "List sessions (persisted + in-memory not yet flushed).", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Sessions, newest first.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSessionsResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createSession", + "tags": [ + "sessions" + ], + "summary": "Create a new session.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Newly created session metadata.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSessionResponse" + } + } + } + } + } + } + }, + "/v1/projects/{projectId}/sessions/{id}/settings": { + "get": { + "operationId": "getSessionSettings", + "tags": [ + "models" + ], + "summary": "Return the active model/thinking settings for a session.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Session model settings.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionModelSettingsResponse" + } + } + } + }, + "404": { + "description": "Unknown session id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "patch": { + "operationId": "updateSessionSettings", + "tags": [ + "models" + ], + "summary": "Switch model and/or thinking level while a session is idle.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchSessionSettingsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Effective session model settings.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionModelSettingsResponse" + } + } + } }, - "service": { - "type": "string", - "enum": [ - "agent-server" - ] + "400": { + "description": "Invalid settings body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, - "time": { - "type": "string" + "404": { + "description": "Unknown session id or model id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, - "channels": { - "type": "object", - "additionalProperties": { - "type": "number" - }, - "description": "Map of SSE channel name → current subscriber count." + "409": { + "description": "Session is currently running.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Unexpected settings update error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - }, - "required": [ - "ok", - "service", - "time", - "channels" - ] + } } }, - "parameters": {} - }, - "paths": { - "/v1/sessions": { + "/v1/projects/{projectId}/sessions/{id}": { "get": { + "operationId": "getSessionMessages", "tags": [ "sessions" ], - "summary": "List sessions (persisted + in-memory not yet flushed).", + "summary": "Persisted message history for a session.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { - "description": "Sessions, newest first.", + "description": "Messages for the session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListSessionsResponse" + "$ref": "#/components/schemas/SessionMessagesResponse" + } + } + } + }, + "404": { + "description": "Unknown session id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } } } }, - "post": { + "delete": { + "operationId": "deleteSession", "tags": [ "sessions" ], - "summary": "Create a new session.", + "summary": "Permanently delete a session and its persisted history.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { - "description": "Newly created session metadata.", + "description": "Session deleted.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateSessionResponse" + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "404": { + "description": "Unknown session id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -194,13 +3465,23 @@ } } }, - "/v1/sessions/{id}": { + "/v1/projects/{projectId}/sessions/{id}/extension-ui": { "get": { + "operationId": "listExtensionUiRequests", "tags": [ - "sessions" + "extensions" ], - "summary": "Persisted message history for a session.", + "summary": "List pending extension UI requests for a session.", "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, { "schema": { "type": "string", @@ -213,11 +3494,11 @@ ], "responses": { "200": { - "description": "Messages for the session.", + "description": "Pending extension UI request events.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionMessagesResponse" + "$ref": "#/components/schemas/PendingExtensionUiRequestsResponse" } } } @@ -235,13 +3516,23 @@ } } }, - "/v1/sessions/{id}/prompt": { + "/v1/projects/{projectId}/sessions/{id}/extension-ui/{requestId}/response": { "post": { + "operationId": "respondExtensionUiRequest", "tags": [ - "sessions" + "extensions" ], - "summary": "Send a user prompt. Events flow over the SSE stream.", + "summary": "Resolve a pending extension UI request.", "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, { "schema": { "type": "string", @@ -250,6 +3541,15 @@ "required": true, "name": "id", "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "requestId", + "in": "path" } ], "requestBody": { @@ -257,14 +3557,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PromptRequest" + "$ref": "#/components/schemas/ExtensionUiResponseRequest" } } } }, "responses": { "200": { - "description": "Prompt accepted and queued.", + "description": "Extension UI response accepted.", "content": { "application/json": { "schema": { @@ -274,7 +3574,7 @@ } }, "404": { - "description": "Unknown session id.", + "description": "Unknown session id or request id.", "content": { "application/json": { "schema": { @@ -286,13 +3586,23 @@ } } }, - "/v1/sessions/{id}/abort": { + "/v1/projects/{projectId}/sessions/{id}/prompt": { "post": { + "operationId": "sendPrompt", "tags": [ "sessions" ], - "summary": "Abort the in-flight run on a session. No-op if idle.", + "summary": "Send a user prompt. Events flow over the SSE stream.", "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, { "schema": { "type": "string", @@ -303,9 +3613,19 @@ "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromptRequest" + } + } + } + }, "responses": { "200": { - "description": "Abort accepted (or no-op if session was idle).", + "description": "Prompt accepted and queued.", "content": { "application/json": { "schema": { @@ -327,19 +3647,50 @@ } } }, - "/v1/healthz": { - "get": { + "/v1/projects/{projectId}/sessions/{id}/abort": { + "post": { + "operationId": "abortSession", "tags": [ - "meta" + "sessions" + ], + "summary": "Abort the in-flight run on a session. No-op if idle.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } ], - "summary": "Liveness + diagnostic counters.", "responses": { "200": { - "description": "OK.", + "description": "Abort accepted (or no-op if session was idle).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HealthResponse" + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "404": { + "description": "Unknown session id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -347,13 +3698,24 @@ } } }, - "/v1/sessions/{id}/events": { + "/v1/projects/{projectId}/sessions/{id}/events": { "get": { + "operationId": "streamSessionEvents", "tags": [ "sessions" ], "summary": "Server-Sent Events stream of pi AgentSessionEvents for the session.", + "description": "Long-lived `text/event-stream`. Each `data:` line carries one JSON `AgentSessionEvent` (see the `AgentSessionEvent` schema). Non-JSON lines occur too: an initial `connected to ` line and periodic `heartbeat` keepalive events, both of which consumers ignore. The event payload is validated against this contract server-side before being forwarded.", "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "projectId", + "in": "path" + }, { "schema": { "type": "string", @@ -366,11 +3728,11 @@ ], "responses": { "200": { - "description": "SSE stream. Each event is `data: ` carrying a pi AgentSessionEvent.", + "description": "SSE stream. Each `data:` line is a JSON-encoded AgentSessionEvent.", "content": { "text/event-stream": { "schema": { - "type": "string" + "$ref": "#/components/schemas/WireEvent" } } } diff --git a/package-lock.json b/package-lock.json index 8e566ea..de2b544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "@appx/agent-server", "version": "0.1.0", "dependencies": { - "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-ai": "0.75.4", + "@earendil-works/pi-coding-agent": "0.75.4", "@hono/node-server": "^1.13.7", "@hono/swagger-ui": "^0.5.1", "@hono/zod-openapi": "^0.19.2", @@ -19,9 +20,14 @@ "agent-server": "dist/server.js" }, "devDependencies": { + "@biomejs/biome": "2.3.5", "@types/node": "^22.0.0", + "ajv": "^8.20.0", + "husky": "^9.1.7", + "ts-patch": "^3.3.0", "tsx": "^4.19.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "typia": "^12.1.1" } }, "node_modules/@anthropic-ai/sdk": { @@ -145,17 +151,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", - "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "version": "3.974.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.14.tgz", + "integrity": "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", + "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -164,15 +170,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", - "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.40.tgz", + "integrity": "sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -180,17 +186,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", - "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.42.tgz", + "integrity": "sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -198,23 +204,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", - "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.44.tgz", + "integrity": "sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-login": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/credential-provider-env": "^3.972.40", + "@aws-sdk/credential-provider-http": "^3.972.42", + "@aws-sdk/credential-provider-login": "^3.972.44", + "@aws-sdk/credential-provider-process": "^3.972.40", + "@aws-sdk/credential-provider-sso": "^3.972.44", + "@aws-sdk/credential-provider-web-identity": "^3.972.44", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -222,16 +228,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", - "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.44.tgz", + "integrity": "sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -239,21 +245,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", - "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.45.tgz", + "integrity": "sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-ini": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", + "@aws-sdk/credential-provider-env": "^3.972.40", + "@aws-sdk/credential-provider-http": "^3.972.42", + "@aws-sdk/credential-provider-ini": "^3.972.44", + "@aws-sdk/credential-provider-process": "^3.972.40", + "@aws-sdk/credential-provider-sso": "^3.972.44", + "@aws-sdk/credential-provider-web-identity": "^3.972.44", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -261,15 +267,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", - "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.40.tgz", + "integrity": "sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -277,17 +283,34 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", - "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.44.tgz", + "integrity": "sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/token-providers": "3.1054.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1054.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1054.0.tgz", + "integrity": "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -295,16 +318,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", - "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.44.tgz", + "integrity": "sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -312,14 +335,14 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", - "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.17.tgz", + "integrity": "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -327,14 +350,14 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", - "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.13.tgz", + "integrity": "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -342,17 +365,17 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", - "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.22.tgz", + "integrity": "sha512-aumo6pYnvD1/eda3R0UDkRVecwxsuW4zTZLdjbHg7NqYMKmy7vK0bM3NGJzCD+Ys8iqCC7EeDU4LuWVIsXvL+A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -360,20 +383,20 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", - "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "version": "3.997.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.12.tgz", + "integrity": "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/signature-v4-multi-region": "^3.996.29", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -381,15 +404,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "version": "3.996.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.29.tgz", + "integrity": "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", + "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -414,12 +436,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -439,13 +461,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz", + "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==", "license": "Apache-2.0", "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, @@ -453,109 +474,2005 @@ "node": ">=20.0.0" } }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.5.tgz", + "integrity": "sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.5", + "@biomejs/cli-darwin-x64": "2.3.5", + "@biomejs/cli-linux-arm64": "2.3.5", + "@biomejs/cli-linux-arm64-musl": "2.3.5", + "@biomejs/cli-linux-x64": "2.3.5", + "@biomejs/cli-linux-x64-musl": "2.3.5", + "@biomejs/cli-win32-arm64": "2.3.5", + "@biomejs/cli-win32-x64": "2.3.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.5.tgz", + "integrity": "sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.5.tgz", + "integrity": "sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.5.tgz", + "integrity": "sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.5.tgz", + "integrity": "sha512-eGUG7+hcLgGnMNl1KHVZUYxahYAhC462jF/wQolqu4qso2MSk32Q+QrpN7eN4jAHAg7FUMIo897muIhK4hXhqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.5.tgz", + "integrity": "sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.5.tgz", + "integrity": "sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.5.tgz", + "integrity": "sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.5.tgz", + "integrity": "sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.4.tgz", + "integrity": "sha512-m/w8Hh3vQ0rAycwJiJWdzkypkn4295f4eq/966lDRy8aX5sk6bgYXH8TQmL16TO7Uwc7MbJG0QoyFHgX8RqXUQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.4.tgz", + "integrity": "sha512-Fb+FRo08b5H9pYKbQJ708/5OKL0+K/yclhfCMEhrBzSPTZZ4c85nY1YsBo4qwL20ohBMlBezHMRuHzcJ1ylEoQ==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.75.4", + "@earendil-works/pi-ai": "^0.75.4", + "@earendil-works/pi-tui": "^0.75.4", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.6" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.4.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.75.4", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.4.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "./dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.4.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "koffi": "2.16.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", + "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.6", + "@mariozechner/clipboard-darwin-universal": "0.3.6", + "@mariozechner/clipboard-darwin-x64": "0.3.6", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", + "@mariozechner/clipboard-linux-x64-musl": "0.3.6", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", + "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", + "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", + "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", + "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", + "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", + "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", + "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", + "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", + "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", + "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/koffi": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", + "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=22.19.0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">= 8" } }, - "node_modules/@earendil-works/pi-agent-core": { - "version": "0.74.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.74.1.tgz", - "integrity": "sha512-K9zedEWr5TTJ21ajX8VgYPzdo9Nd4l0xGsHztXTL1aW4XA/74Lwrt9s8V7AdMIlu3WZ9szJ+BY2h7o1rqkUH9A==", - "license": "MIT", + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", "dependencies": { - "@earendil-works/pi-ai": "^0.74.1", - "ignore": "^7.0.5", - "typebox": "^1.1.24", - "yaml": "^2.8.2" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": ">=20.0.0" + "node": ">= 8" } }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.74.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.74.1.tgz", - "integrity": "sha512-xBgJnsrB+eCIsEB2rQ+aD/goGDo/YJMzQoICyL+ltHSB0SVQDgawjCzNF1IuRCzvUFceqZGJETUiejfWRDUe0Q==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "^0.91.1", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "openai": "6.26.0", - "partial-json": "^0.1.7", - "typebox": "^1.1.24" + "engines": { + "node": ">=10.0.0" }, - "bin": { - "pi-ai": "dist/cli.js" + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=16.0.0" } }, - "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.74.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.74.1.tgz", - "integrity": "sha512-kJpWufgXJOBbZywAoup1QLeJmjCjIK49gtYtmbY+ibjZbINHOlBHEKpuo2RUfuJN9yvfh90iUsptdVW0uNn0Dg==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-agent-core": "^0.74.1", - "@earendil-works/pi-ai": "^0.74.1", - "@earendil-works/pi-tui": "^0.74.1", - "@silvia-odwyer/photon-node": "^0.3.4", - "chalk": "^5.5.0", - "diff": "^8.0.2", - "glob": "^13.0.1", - "highlight.js": "^10.7.3", - "hosted-git-info": "^9.0.2", - "ignore": "^7.0.5", - "jiti": "^2.7.0", - "minimatch": "^10.2.3", - "proper-lockfile": "^4.1.2", - "typebox": "^1.1.24", - "undici": "^7.19.1", - "yaml": "^2.8.2" - }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", "bin": { - "pi": "dist/cli.js" + "yaml": "bin.mjs" }, "engines": { - "node": ">=20.6.0" + "node": ">= 14.6" }, - "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.6" + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/@earendil-works/pi-tui": { - "version": "0.74.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.74.1.tgz", - "integrity": "sha512-wQj2TRG43/BBNyd/lReQJWtTCOJVyIwI+7Ifp3hNITfTtQr4zQdMeF7uxqEMQ73LI6rQO7Ljx0P2w+oMx8uyIA==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12" - }, - "engines": { - "node": ">=20.0.0" - }, - "optionalDependencies": { - "koffi": "^2.9.0" + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1036,220 +2953,63 @@ "hono": "^4" } }, - "node_modules/@hono/swagger-ui": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@hono/swagger-ui/-/swagger-ui-0.5.3.tgz", - "integrity": "sha512-Hn90DOOJ62ICJQplQvCDVpi9Jcn6EhtRaiffyJIS53wA5RmRLtMCDQGVc0bor8vQD7JIwpkweWjs+3cycp+IvA==", - "license": "MIT", - "peerDependencies": { - "hono": ">=4.0.0" - } - }, - "node_modules/@hono/zod-openapi": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-0.19.10.tgz", - "integrity": "sha512-dpoS6DenvoJyvxtQ7Kd633FRZ/Qf74+4+o9s+zZI8pEqnbjdF/DtxIib08WDpCaWabMEJOL5TXpMgNEZvb7hpA==", - "license": "MIT", - "dependencies": { - "@asteasolutions/zod-to-openapi": "^7.3.0", - "@hono/zod-validator": "^0.7.1", - "openapi3-ts": "^4.5.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "hono": ">=4.3.6", - "zod": ">=3.0.0" - } - }, - "node_modules/@hono/zod-validator": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.7.6.tgz", - "integrity": "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==", - "license": "MIT", - "peerDependencies": { - "hono": ">=3.9.0", - "zod": "^3.25.0 || ^4.0.0" - } - }, - "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" - } - }, - "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", - "cpu": [ - "x64" - ], + "node_modules/@hono/swagger-ui": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@hono/swagger-ui/-/swagger-ui-0.5.3.tgz", + "integrity": "sha512-Hn90DOOJ62ICJQplQvCDVpi9Jcn6EhtRaiffyJIS53wA5RmRLtMCDQGVc0bor8vQD7JIwpkweWjs+3cycp+IvA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "peerDependencies": { + "hono": ">=4.0.0" } }, - "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", - "cpu": [ - "x64" - ], + "node_modules/@hono/zod-openapi": { + "version": "0.19.10", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-0.19.10.tgz", + "integrity": "sha512-dpoS6DenvoJyvxtQ7Kd633FRZ/Qf74+4+o9s+zZI8pEqnbjdF/DtxIib08WDpCaWabMEJOL5TXpMgNEZvb7hpA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.0", + "@hono/zod-validator": "^0.7.1", + "openapi3-ts": "^4.5.0" + }, "engines": { - "node": ">= 10" + "node": ">=16.0.0" + }, + "peerDependencies": { + "hono": ">=4.3.6", + "zod": ">=3.0.0" } }, - "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", - "cpu": [ - "arm64" - ], + "node_modules/@hono/zod-validator": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.7.6.tgz", + "integrity": "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "peerDependencies": { + "hono": ">=3.9.0", + "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, "engines": { - "node": ">= 10" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@mistralai/mistralai": { @@ -1294,9 +3054,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { @@ -1315,9 +3075,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -1338,16 +3098,10 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, "node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", + "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", @@ -1359,12 +3113,12 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", + "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", + "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, @@ -1373,12 +3127,12 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", + "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", + "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, @@ -1399,12 +3153,12 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", + "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", + "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, @@ -1413,12 +3167,12 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", + "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", + "@smithy/core": "^3.24.4", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, @@ -1464,6 +3218,13 @@ "node": ">=14.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", @@ -1479,6 +3240,46 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "license": "MIT" }, + "node_modules/@typia/core": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@typia/core/-/core-12.1.1.tgz", + "integrity": "sha512-SfyugTNTCJa75pVwSXwfx6SwvS5YcNOCBg8VjxqykhSgCyoAXkZtc2PsWEkeeaB8EPut1JRBCrzlWtsXo1EZ8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typia/interface": "^12.1.1", + "@typia/utils": "^12.1.1" + } + }, + "node_modules/@typia/interface": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@typia/interface/-/interface-12.1.1.tgz", + "integrity": "sha512-FKLpgNX1mrGnPfeXhU6ztRyMhLvuK13OY8MgqaIucl59XNyCVtcRqgnCMc7dJGLpXEveVp3N5a5VGyZdNUHnCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typia/transform": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@typia/transform/-/transform-12.1.1.tgz", + "integrity": "sha512-RbWB9L/aqcBTbPrJBOYpIg8IyQh6eGliElAFww40F7QlgsXIlk13twjTV3EEWXvgvOTl5eOSDWUKYgL7iAgoOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typia/core": "^12.1.1", + "@typia/interface": "^12.1.1", + "@typia/utils": "^12.1.1" + } + }, + "node_modules/@typia/utils": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@typia/utils/-/utils-12.1.1.tgz", + "integrity": "sha512-RQSHMEyVfPnpQJHGfFe2pxU1H5lJPUBF9CQcCB6/EtI+QKcBj/QmQBOJX7a/WRyvC5ojJlJGL0DEylufhBKxkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typia/interface": "^12.1.1" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1488,15 +3289,72 @@ "node": ">= 14" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1526,22 +3384,47 @@ "node": "*" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, "node_modules/buffer-equal-constant-time": { @@ -1551,15 +3434,117 @@ "license": "BSD-3-Clause" }, "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/comment-json": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", + "dev": true, "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "engines": { + "node": ">= 6" } }, "node_modules/data-uri-to-buffer": { @@ -1588,13 +3573,27 @@ } } }, - "node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "license": "BSD-3-Clause", + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.3.1" + "node": ">=4" } }, "node_modules/ecdsa-sig-formatter": { @@ -1606,6 +3605,23 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", @@ -1648,12 +3664,60 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -1714,6 +3778,22 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -1741,6 +3821,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gaxios": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", @@ -1769,33 +3859,19 @@ "node": ">=18" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=16" } }, "node_modules/google-auth-library": { @@ -1824,19 +3900,27 @@ "node": ">=14" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, "engines": { - "node": "*" + "node": ">= 0.4" } }, "node_modules/hono": { @@ -1848,18 +3932,6 @@ "node": ">=16.9.0" } }, - "node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1886,22 +3958,161 @@ "node": ">= 14" } }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, "license": "MIT", + "bin": { + "husky": "bin.js" + }, "engines": { - "node": ">= 4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" } }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, "node_modules/json-bigint": { @@ -1926,6 +4137,13 @@ "node": ">=16" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -1947,66 +4165,64 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/koffi": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", - "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, "engines": { - "node": ">= 18" + "node": ">=0.10.0" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/ms": { @@ -2015,6 +4231,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -2053,6 +4276,22 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openai": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", @@ -2083,6 +4322,30 @@ "yaml": "^2.8.0" } }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -2096,6 +4359,16 @@ "node": ">=8" } }, + "node_modules/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, "node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", @@ -2117,64 +4390,137 @@ "node": ">=14.0.0" } }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=12.0.0" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=4" } }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/proper-lockfile/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=0.10.0" } }, - "node_modules/protobufjs": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", - "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", - "hasInstallScript": true, - "license": "BSD-3-Clause", + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "engines": { - "node": ">=12.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/retry": { @@ -2186,6 +4532,26 @@ "node": ">= 4" } }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2206,12 +4572,71 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -2224,12 +4649,64 @@ ], "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-patch": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ts-patch/-/ts-patch-3.3.0.tgz", + "integrity": "sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "global-prefix": "^4.0.0", + "minimist": "^1.2.8", + "resolve": "^1.22.2", + "semver": "^7.6.3", + "strip-ansi": "^6.0.1" + }, + "bin": { + "ts-patch": "bin/ts-patch.js", + "tspc": "bin/tspc.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2255,6 +4732,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typebox": { "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", @@ -2275,13 +4765,29 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "node_modules/typia": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/typia/-/typia-12.1.1.tgz", + "integrity": "sha512-sgUjpsSW8EhvVoLVbalMyejo9XT42cJUPqFPmXPdf/bIh7xY0rIiNF4CJc9oTXZpqtiZR9II7M8qZ0dYkg93Gw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=20.18.1" + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@typia/core": "^12.1.1", + "@typia/interface": "^12.1.1", + "@typia/transform": "^12.1.1", + "@typia/utils": "^12.1.1", + "commander": "^10.0.0", + "comment-json": "^4.2.3", + "inquirer": "^8.2.5", + "package-manager-detector": "^0.2.0", + "randexp": "^0.5.3" + }, + "bin": { + "typia": "lib/executable/typia.js" + }, + "peerDependencies": { + "typescript": ">=4.8.0 <7.0.0" } }, "node_modules/undici-types": { @@ -2290,6 +4796,23 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -2299,10 +4822,41 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 17d5fab..e170948 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@appx/agent-server", "private": true, "version": "0.1.0", - "description": "Pi-SDK-based agent orchestration server. Runs as a standalone HTTP/SSE service per Appx app.", + "description": "Pi-SDK-based agent orchestration server for standalone and project-scoped sessions.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -16,14 +16,19 @@ "agent-server": "dist/server.js" }, "scripts": { - "build": "tsc", - "dev": "tsx watch src/server.ts", - "start": "node dist/server.js", - "openapi": "tsx src/openapi.ts", + "build": "tsc && node -e \"require('fs').cpSync('src/contract/eventSchema.generated.json','dist/contract/eventSchema.generated.json')\"", + "typecheck": "tsc --noEmit", + "check": "biome check --write --error-on-warnings .", + "prepare": "husky", + "dev": "tsx watch --env-file-if-exists=.env src/server.ts", + "start": "node --env-file-if-exists=.env dist/server.js", + "openapi": "tsx --env-file-if-exists=.env src/contract/openapi.ts", + "gen:event-schema": "tspc -p tsconfig.gen.json && node .gen/scripts/genEventSchema.js && rm -rf .gen", "test": "tsx --test test/*.test.ts" }, "dependencies": { - "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-ai": "0.75.4", + "@earendil-works/pi-coding-agent": "0.75.4", "@hono/node-server": "^1.13.7", "@hono/swagger-ui": "^0.5.1", "@hono/zod-openapi": "^0.19.2", @@ -31,8 +36,13 @@ "zod": "^3.24.1" }, "devDependencies": { + "@biomejs/biome": "2.3.5", "@types/node": "^22.0.0", + "ajv": "^8.20.0", + "husky": "^9.1.7", + "ts-patch": "^3.3.0", "tsx": "^4.19.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "typia": "^12.1.1" } } diff --git a/scripts/genEventSchema.ts b/scripts/genEventSchema.ts new file mode 100644 index 0000000..5979ad9 --- /dev/null +++ b/scripts/genEventSchema.ts @@ -0,0 +1,71 @@ +/** + * Build-time generator for the SSE wire-event JSON Schema. + * + * Runs typia over the `WireEvent` TypeScript type and emits an OpenAPI 3.1 + * schema collection to `src/contract/eventSchema.generated.json` (committed). The + * normal `tsc` build, the `openapi` dump, and the server runtime all read that + * committed JSON, so typia/ts-patch are only needed here, when regenerating + * (e.g. after a pi upgrade). + * + * typia is a compile-time transformer, so this file must be compiled with the + * typia transform applied (via `tsconfig.gen.json` + ts-patch's `tspc`); run it + * with `npm run gen:event-schema`. + * + * typia names component schemas after their instantiated type, which yields a + * few names containing characters that are awkward for downstream codegen (e.g. + * `ToolResultMessageany.o1`). We sanitize those to safe identifiers and rewrite + * every `$ref` accordingly before writing. + */ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import typia from "typia"; +import type { WireEvent } from "../src/contract/wireEvents.js"; +import type { ExtensionUiRequest } from "../src/shared/extensionUi.js"; + +type JsonSchemaCollection = { + version: string; + components: { schemas: Record }; + schemas: Array<{ $ref: string }>; +}; + +/** Map a typia component name to a safe OpenAPI/TS-friendly identifier. */ +function safeName(name: string): string { + return name.replace(/[^A-Za-z0-9_]/g, "_"); +} + +/** + * Rename component schemas with unsafe characters and rewrite every `$ref` to + * match. Longer names are replaced first so a renamed name that is a prefix of + * another (e.g. `Foo` vs `Foo.o1`) can't partially clobber it. + */ +function sanitize(collection: JsonSchemaCollection): JsonSchemaCollection { + const rename = new Map(); + for (const key of Object.keys(collection.components.schemas)) { + const safe = safeName(key); + if (safe !== key) rename.set(key, safe); + } + + let serialized = JSON.stringify(collection); + for (const [from, to] of [...rename].sort((a, b) => b[0].length - a[0].length)) { + serialized = serialized.split(`#/components/schemas/${from}`).join(`#/components/schemas/${to}`); + } + + const out = JSON.parse(serialized) as JsonSchemaCollection; + const renamedSchemas: Record = {}; + for (const [key, value] of Object.entries(out.components.schemas)) { + renamedSchemas[rename.get(key) ?? key] = value; + } + out.components.schemas = renamedSchemas; + return out; +} + +// `WireEvent` MUST stay first: `openapiEventSchema.ts` treats `schemas[0]` as the +// root wire-event ref. The extra entries force typia to emit named components +// (`ExtensionUiRequest`, and `AgentMessage` transitively) so the REST responses +// that forward these shapes can `$ref` them instead of being typed `unknown[]`. +const collection = typia.json.schemas<[WireEvent, ExtensionUiRequest], "3.1">() as unknown as JsonSchemaCollection; +const sanitized = sanitize(collection); + +const outPath = resolve(process.cwd(), "src/contract/eventSchema.generated.json"); +writeFileSync(outPath, `${JSON.stringify(sanitized, null, 2)}\n`); +console.log(`[gen:event-schema] wrote ${outPath} (${Object.keys(sanitized.components.schemas).length} components)`); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..a578628 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,223 @@ +/** + * Server configuration loaded from environment variables. + * + * Single source of truth for the env-var contract: shape, defaults, + * coercion, and validation all live in the Zod schema below. The rest + * of the codebase consumes the typed `ServerConfig` object instead of + * touching `process.env` directly — fail-fast at the boundary, twelve- + * factor "config in env" with proper validation. + * + * Routing is always project-scoped (`/v1/projects/{id}/...`); there is + * no single/multi mode switch. A standalone deployment is simply a + * workspace that holds one project. See + * docs/architecture/project-lifecycle-and-workspace-layout.md. + * + * Conventions + * ─────────── + * - Enum-valued vars accept exactly the canonical names listed below; + * anything else is rejected with a clear error. No aliases, no case + * folding. Strict-in-what-you-accept beats permissive-and-surprising. + * - Boolean-valued vars accept exactly "true" or "false" (lowercase). + * Unset → false. Anything else (e.g. "yes", "1", "True") is rejected. + * Matches GitHub Actions / 12-factor convention. + * - Empty / whitespace-only values are treated as unset. + * + * Filesystem convention + * ───────────────────── + * Everything lives under one mountable root, `WORKSPACE_DIR`: + * - Org-shared (`WORKSPACE_DIR/.pi-global/`): + * auth.json, models.json (Pi), plus projects.json (agent-server's + * durable project registry) and sessions/{id}/ (transcripts). + * Org-scoped (one agent-server process = one org). + * - Project tier (`WORKSPACE_DIR/{id}/.pi/`): + * AGENTS.md, skills/, extensions/, settings.json. Per project, + * config-only (committable) — transcripts live centrally under + * `.pi-global/sessions/{id}/`, not here. + * + * Project directories are created on demand by the project lifecycle + * endpoints (`POST /v1/projects`); operators only configure + * `WORKSPACE_DIR`. If a project has no `.pi/AGENTS.md`, its runtime + * starts with no pinned prompt (silent skip). + * + * Pi additionally auto-discovers user-level resources from + * `~/.pi/agent/skills/`, `~/.agents/skills/`, etc. if they exist; + * agent-server inherits that for free but does not treat those + * locations as part of its own contract. + * + * Environment variables + * ───────────────────── + * WORKSPACE_DIR (required) root holding every project dir + * plus `.pi-global/`. Must exist on disk. + * Mount as a Docker volume for restart-safe + * projects + registry. + * ANTHROPIC_API_KEY injected into pi's AuthStorage if set + * + * PI_EXTENSION_PATHS comma-separated extension/package sources + * (npm:, git:, or filesystem paths) + * PI_SKILL_PATHS comma-separated skill file/directory paths + * PI_PROMPT_PATHS comma-separated prompt template paths + * PI_THEME_PATHS comma-separated theme paths + * PI_NO_EXTENSIONS "true" → disables project/global extension + * discovery except PI_EXTENSION_PATHS + * PI_NO_SKILLS "true" → disables project/global skill discovery + * PI_NO_PROMPTS "true" → disables project/global prompt discovery + * PI_NO_THEMES "true" → disables project/global theme discovery + * + * AGENT_SERVER_HOST bind host (default: 127.0.0.1) + * AGENT_SERVER_PORT bind port (default: 4001) + * AGENT_SERVER_TOKEN if set, /v1/* requires Bearer auth. + * APPX_AGENT_SERVER_TOKEN is a legacy alias. + * + * LITELLM_* variables are owned by `./providers/litellm.ts` and parsed + * separately at the same boundary. + */ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { z } from "zod"; + +/** + * Treat empty / whitespace-only env vars as unset (POSIX convention). + * Trims surrounding whitespace from non-empty values so downstream + * consumers don't have to. + */ +const blankToUndefined = (value: unknown): unknown => { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +/** Required string field. Empty / whitespace-only counts as missing. */ +const requiredString = z.preprocess(blankToUndefined, z.string({ required_error: "is required" })); + +/** Optional string field. Empty → undefined. */ +const optionalString = z.preprocess(blankToUndefined, z.string().optional()); + +/** Optional string with an explicit default. Empty → default. */ +const stringWithDefault = (defaultValue: string) => z.preprocess(blankToUndefined, z.string().default(defaultValue)); + +/** Comma-separated list → string[]; empty entries dropped. */ +const commaList = z.preprocess(blankToUndefined, z.string().optional()).transform((raw) => + (raw ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), +); + +/** + * Strict boolean env flag. Accepts exactly "true" or "false" (lowercase). + * Unset / blank → false. Anything else is rejected with a clear error. + * + * Industry convention (12-factor, GitHub Actions, GoogleSRE): one canonical + * spelling per value. Permissive parsers ("yes"/"on"/"1"/"True") look + * friendly but make config files harder to grep for and let typos like + * "flase" silently coerce to false. + */ +const booleanFlag = z + .preprocess( + blankToUndefined, + z + .enum(["true", "false"], { + errorMap: () => ({ message: 'must be "true" or "false"' }), + }) + .optional(), + ) + .transform((value) => value === "true"); + +/** + * Raw env schema. Coerces primitives but defers cross-field path + * resolution and filesystem checks to `loadConfig()` below — schemas + * stay pure (no I/O), which keeps tests trivial to mock. + */ +const RawEnv = z.object({ + WORKSPACE_DIR: requiredString, + + ANTHROPIC_API_KEY: optionalString, + + PI_EXTENSION_PATHS: commaList, + PI_SKILL_PATHS: commaList, + PI_PROMPT_PATHS: commaList, + PI_THEME_PATHS: commaList, + PI_NO_EXTENSIONS: booleanFlag, + PI_NO_SKILLS: booleanFlag, + PI_NO_PROMPTS: booleanFlag, + PI_NO_THEMES: booleanFlag, + + AGENT_SERVER_HOST: stringWithDefault("127.0.0.1"), + AGENT_SERVER_PORT: z.preprocess(blankToUndefined, z.coerce.number().int().positive().max(65535).default(4001)), + AGENT_SERVER_TOKEN: optionalString, + APPX_AGENT_SERVER_TOKEN: optionalString, +}); + +/** Fully resolved, validated server configuration. */ +export type ServerConfig = { + /** Root holding every project dir plus `.pi-global/`. */ + workspaceDir: string; + anthropicApiKey: string | undefined; + extensionPaths: string[]; + skillPaths: string[]; + promptTemplatePaths: string[]; + themePaths: string[]; + noExtensions: boolean; + noSkills: boolean; + noPromptTemplates: boolean; + noThemes: boolean; + host: string; + port: number; + token: string | undefined; +}; + +/** + * Thrown by `loadConfig()` when the environment is invalid. Callers + * are expected to print `.message` and exit with a non-zero status. + */ +export class ConfigError extends Error { + readonly issues: readonly string[]; + + constructor(issues: readonly string[]) { + super(`invalid configuration:\n${issues.map((issue) => ` ${issue}`).join("\n")}`); + this.name = "ConfigError"; + this.issues = issues; + } +} + +/** + * Load + validate server configuration from the given env source + * (defaults to `process.env`). Throws `ConfigError` with all collected + * issues so the entrypoint can print and exit fast. + */ +export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { + const parsed = RawEnv.safeParse(env); + if (!parsed.success) { + const issues = parsed.error.issues.map((issue) => { + const key = issue.path.join(".") || "(root)"; + return `${key}: ${issue.message}`; + }); + throw new ConfigError(issues); + } + const raw = parsed.data; + + const workspaceDir = resolve(raw.WORKSPACE_DIR); + if (!existsSync(workspaceDir)) { + throw new ConfigError([`WORKSPACE_DIR does not exist: ${workspaceDir}`]); + } + + // AGENT_SERVER_TOKEN wins over the legacy APPX_AGENT_SERVER_TOKEN + // alias when both are set. + const token = raw.AGENT_SERVER_TOKEN ?? raw.APPX_AGENT_SERVER_TOKEN; + + return { + workspaceDir, + anthropicApiKey: raw.ANTHROPIC_API_KEY, + extensionPaths: raw.PI_EXTENSION_PATHS, + skillPaths: raw.PI_SKILL_PATHS, + promptTemplatePaths: raw.PI_PROMPT_PATHS, + themePaths: raw.PI_THEME_PATHS, + noExtensions: raw.PI_NO_EXTENSIONS, + noSkills: raw.PI_NO_SKILLS, + noPromptTemplates: raw.PI_NO_PROMPTS, + noThemes: raw.PI_NO_THEMES, + host: raw.AGENT_SERVER_HOST, + port: raw.AGENT_SERVER_PORT, + token, + }; +} diff --git a/src/contract/README.md b/src/contract/README.md new file mode 100644 index 0000000..348a78c --- /dev/null +++ b/src/contract/README.md @@ -0,0 +1,140 @@ +# `contract/` — the published API contract + +This directory is the **single source of truth for agent-server's typed surface**: +the REST DTOs, the SSE wire-event union, and the machinery that turns them into a +language-neutral `openapi.json`. Everything a downstream consumer (appx, lanquest, +eventx, …) codegens against originates here. + +The guiding principle: **pi owns its shapes, agent-server owns and versions the +published contract, consumers codegen from `openapi.json` — nothing is +hand-mirrored.** + +## How the types flow + +``` +pi TypeScript types ──┐ + (AgentSessionEvent, │ typia (compile-time) ┌─ openapi.json ─┐ openapi-typescript + AssistantMessage…) ├─▶ eventSchema.generated.json ─┤ (published ├─▶ generated TS types + │ │ contract) │ + openapi-fetch client + zod REST schemas ────┘ @hono/zod-openapi └────────────────┘ (consumer side) + (schemas.ts) +``` + +Two halves merge into one document: + +1. **REST surface** — authored as zod in [`schemas.ts`](./schemas.ts) and turned + into OpenAPI paths by `@hono/zod-openapi`. These schemas double as **runtime + request/response validation** in the route handlers, so the contract and the + validation can't diverge. +2. **SSE surface** — the `WireEvent` union in [`wireEvents.ts`](./wireEvents.ts) + (= pi's `AgentSessionEvent` + the events agent-server injects). It is **not** + hand-authored as a schema: `scripts/genEventSchema.ts` runs + [typia](https://typia.io) over the TypeScript type to emit + [`eventSchema.generated.json`](./eventSchema.generated.json) (committed), which + [`openapiEventSchema.ts`](./openapiEventSchema.ts) merges into the document. + +## Files + +| File | Role | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `schemas.ts` | zod REST DTOs — runtime validation **and** OpenAPI source. | +| `wireEvents.ts` | The `WireEvent` SSE union (pi composition). The typia input — the SSE contract's source of truth. | +| `eventSchema.generated.json` | typia output for `WireEvent`. **Committed, generated — never edit by hand.** | +| `openapiEventSchema.ts` | Merges the event schema into the doc and exposes `buildOpenApiDocument()` — the one function both the live server and the static dump use, so they can't drift. Also defines `OPENAPI_INFO` (title/**version**/description). | +| `openapi.ts` | Build-time dump: mounts the routes and writes the repo-root `openapi.json`. Thin wrapper over `buildOpenApiDocument()`. | +| `eventValidation.ts` | Runtime, tolerant classification of outgoing SSE events against the contract (`valid` / `unknown-type` / `invalid`). Observability/forward-compat only — events are forwarded regardless. | + +The published document is available two ways, always identical apart from the +`servers` block (the live endpoint advertises its address; the dump stays +host-agnostic): + +- **Live:** `GET /openapi.json` (Swagger UI at `/docs`). +- **Static:** the committed `openapi.json` at the repo root. + +## Manual commands + +Regeneration is **not** part of the normal `tsc` build (typia needs the +ts-patch transform, so it only runs on demand). Run these after the source +types change: + +```bash +# 1. After a pi upgrade OR any change to WireEvent (wireEvents.ts): +# re-emit the typia event schema (needs ts-patch via `tspc`). +npm run gen:event-schema # → src/contract/eventSchema.generated.json + +# 2. After ANY contract change (zod schema, route, or step 1): +# rebuild and dump the merged document. +npm run openapi # → ./openapi.json +``` + +When you make an **intentional** breaking change, also bump `OPENAPI_INFO.version` +in `openapiEventSchema.ts` so consumers can pin and upgrade deliberately. + +> The normal `npm run build` reads the already-committed +> `eventSchema.generated.json` (and copies it into `dist/`); it does **not** +> regenerate it. + +## CI gates + +`.github/workflows/contract.yml` protects `main` so drift is never silent: + +- **Freshness** — regenerates both artifacts and fails if the committed + `openapi.json` / `eventSchema.generated.json` are stale (i.e. you bumped pi or + edited a route but forgot to regenerate + commit). +- **Breaking changes** — [`oasdiff`](https://github.com/oasdiff/oasdiff) diffs the + PR's `openapi.json` against the base branch and fails on breaking changes, + turning a pi-driven shape change into a reviewed, intentional event. + +## How downstream consumers use it + +The contract is language-neutral, so the canonical path is the same for every +consumer (lanquest, eventx, appx web clients, future non-TS SDKs): + +**1. Vendor the contract** (commit the snapshot for reproducible builds): + +```bash +# from a live server: +curl -s http://127.0.0.1:4001/openapi.json -o openapi/agent-server.json +# or copy the committed dump: +cp /path/to/agent-server/openapi.json openapi/agent-server.json +``` + +**2. Generate types** with [`openapi-typescript`](https://openapi-ts.dev): + +```bash +openapi-typescript openapi/agent-server.json -o src/agent-server.generated.ts +``` + +**3. Consume** — reference the generated types and (for REST) drive a typed +client from the same `paths`, so request/response shapes are inferred and +contract-checked: + +```ts +import createClient from "openapi-fetch"; +import type { paths, components } from "./agent-server.generated"; + +const http = createClient({ baseUrl: "/agent" }); +const { data } = await http.GET("/v1/projects"); // typed from the contract + +type WireEvent = components["schemas"]["WireEvent"]; // SSE events (EventSource) +type AgentMessage = components["schemas"]["AgentMessage"]; +``` + +> **Reference implementation:** `lanquest`'s `agent-chat-ui` package does exactly +> this — `npm run gen:api` regenerates the types, `core/types.ts` re-exports clean +> aliases over `components['schemas']`, and `core/client.ts` wraps `openapi-fetch`. +> Every route carries an `operationId`, so the generated `operations` map and any +> future multi-language SDK get stable, human-readable names. + +**Node embedders** (hosts that mount agent-server's routes in their own process, +e.g. appx) can instead import types straight from the package — `src/index.ts` +re-exports the runtime DTOs and pi's `AgentSessionEvent`. Prefer the +`openapi.json` path for browser/SDK clients; it keeps consumers decoupled from +agent-server's internal TypeScript and enables non-TS SDKs. + +## The one rule + +Don't hand-write contract types in a consumer, and don't hand-edit +`eventSchema.generated.json`. Change the source (`schemas.ts` / `wireEvents.ts`), +regenerate, commit, let the CI gates classify the change, then re-vendor +downstream. diff --git a/src/contract/eventSchema.generated.json b/src/contract/eventSchema.generated.json new file mode 100644 index 0000000..bad0574 --- /dev/null +++ b/src/contract/eventSchema.generated.json @@ -0,0 +1,1867 @@ +{ + "version": "3.1", + "components": { + "schemas": { + "WireEvent": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "select" + }, + "title": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title", + "options" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "confirm" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "input" + }, + "title": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "editor" + }, + "title": { + "type": "string" + }, + "prefill": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "notify" + }, + "message": { + "type": "string" + }, + "notifyType": { + "oneOf": [ + { + "const": "info" + }, + { + "const": "warning" + }, + { + "const": "error" + } + ] + } + }, + "required": [ + "type", + "id", + "method", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setStatus" + }, + "statusKey": { + "type": "string" + }, + "statusText": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "statusKey" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setWidget" + }, + "widgetKey": { + "type": "string" + }, + "widgetLines": { + "type": "array", + "items": { + "type": "string" + } + }, + "widgetPlacement": { + "oneOf": [ + { + "const": "aboveEditor" + }, + { + "const": "belowEditor" + } + ] + } + }, + "required": [ + "type", + "id", + "method", + "widgetKey" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setTitle" + }, + "title": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "set_editor_text" + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "text" + ] + }, + { + "$ref": "#/components/schemas/ExtensionErrorEvent" + }, + { + "type": "object", + "properties": { + "type": { + "const": "agent_start" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "turn_start" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "turn_end" + }, + "message": { + "$ref": "#/components/schemas/AgentMessage" + }, + "toolResults": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolResultMessageany_o1" + } + } + }, + "required": [ + "type", + "message", + "toolResults" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "message_start" + }, + "message": { + "$ref": "#/components/schemas/AgentMessage" + } + }, + "required": [ + "type", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "message_update" + }, + "message": { + "$ref": "#/components/schemas/AgentMessage" + }, + "assistantMessageEvent": { + "$ref": "#/components/schemas/AssistantMessageEvent" + } + }, + "required": [ + "type", + "message", + "assistantMessageEvent" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "message_end" + }, + "message": { + "$ref": "#/components/schemas/AgentMessage" + } + }, + "required": [ + "type", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "tool_execution_start" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "args": {} + }, + "required": [ + "type", + "toolCallId", + "toolName", + "args" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "tool_execution_update" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "args": {}, + "partialResult": {} + }, + "required": [ + "type", + "toolCallId", + "toolName", + "args", + "partialResult" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "tool_execution_end" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "result": {}, + "isError": { + "type": "boolean" + } + }, + "required": [ + "type", + "toolCallId", + "toolName", + "result", + "isError" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "agent_end" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentMessage" + } + }, + "willRetry": { + "type": "boolean" + } + }, + "required": [ + "type", + "messages", + "willRetry" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "queue_update" + }, + "steering": { + "type": "array", + "items": { + "type": "string" + } + }, + "followUp": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "steering", + "followUp" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "compaction_start" + }, + "reason": { + "oneOf": [ + { + "const": "manual" + }, + { + "const": "threshold" + }, + { + "const": "overflow" + } + ] + } + }, + "required": [ + "type", + "reason" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "session_info_changed" + }, + "name": { + "type": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "thinking_level_changed" + }, + "level": { + "$ref": "#/components/schemas/ThinkingLevel" + } + }, + "required": [ + "type", + "level" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "compaction_end" + }, + "reason": { + "oneOf": [ + { + "const": "manual" + }, + { + "const": "threshold" + }, + { + "const": "overflow" + } + ] + }, + "result": { + "$ref": "#/components/schemas/CompactionResultunknown" + }, + "aborted": { + "type": "boolean" + }, + "willRetry": { + "type": "boolean" + }, + "errorMessage": { + "type": "string" + } + }, + "required": [ + "type", + "reason", + "aborted", + "willRetry" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "auto_retry_start" + }, + "attempt": { + "type": "number" + }, + "maxAttempts": { + "type": "number" + }, + "delayMs": { + "type": "number" + }, + "errorMessage": { + "type": "string" + } + }, + "required": [ + "type", + "attempt", + "maxAttempts", + "delayMs", + "errorMessage" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "auto_retry_end" + }, + "success": { + "type": "boolean" + }, + "attempt": { + "type": "number" + }, + "finalError": { + "type": "string" + } + }, + "required": [ + "type", + "success", + "attempt" + ] + } + ], + "description": "Every JSON event agent-server forwards on `GET …/sessions/{id}/events`." + }, + "ExtensionErrorEvent": { + "type": "object", + "properties": { + "type": { + "const": "extension_error" + }, + "extensionPath": { + "type": "string" + }, + "event": { + "type": "string", + "description": "The pi lifecycle event during which the error occurred (e.g. \"session_start\")." + }, + "error": { + "type": "string" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "extensionPath", + "error" + ], + "description": "Emitted when a pi extension handler throws; surfaced to the UI for visibility." + }, + "AgentMessage": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, + { + "$ref": "#/components/schemas/AssistantMessage" + }, + { + "$ref": "#/components/schemas/ToolResultMessageany" + }, + { + "$ref": "#/components/schemas/BashExecutionMessage" + }, + { + "$ref": "#/components/schemas/CustomMessageunknown" + }, + { + "$ref": "#/components/schemas/BranchSummaryMessage" + }, + { + "$ref": "#/components/schemas/CompactionSummaryMessage" + } + ], + "discriminator": { + "propertyName": "role", + "mapping": { + "user": "#/components/schemas/UserMessage", + "assistant": "#/components/schemas/AssistantMessage", + "toolResult": "#/components/schemas/ToolResultMessageany", + "bashExecution": "#/components/schemas/BashExecutionMessage", + "custom": "#/components/schemas/CustomMessageunknown", + "branchSummary": "#/components/schemas/BranchSummaryMessage", + "compactionSummary": "#/components/schemas/CompactionSummaryMessage" + } + }, + "description": "AgentMessage: Union of LLM messages + custom messages.\nThis abstraction allows apps to add custom message types while maintaining\ntype safety and compatibility with the base LLM messages." + }, + "UserMessage": { + "type": "object", + "properties": { + "role": { + "const": "user" + }, + "content": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ImageContent" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "image": "#/components/schemas/ImageContent" + } + } + } + } + ] + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "content", + "timestamp" + ] + }, + "TextContent": { + "type": "object", + "properties": { + "type": { + "const": "text" + }, + "text": { + "type": "string" + }, + "textSignature": { + "type": "string" + } + }, + "required": [ + "type", + "text" + ] + }, + "ImageContent": { + "type": "object", + "properties": { + "type": { + "const": "image" + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "type", + "data", + "mimeType" + ] + }, + "AssistantMessage": { + "type": "object", + "properties": { + "role": { + "const": "assistant" + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ThinkingContent" + }, + { + "$ref": "#/components/schemas/ToolCall" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "thinking": "#/components/schemas/ThinkingContent", + "toolCall": "#/components/schemas/ToolCall" + } + } + } + }, + "api": { + "$ref": "#/components/schemas/Api" + }, + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "responseModel": { + "type": "string" + }, + "responseId": { + "type": "string" + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantMessageDiagnostic" + } + }, + "usage": { + "$ref": "#/components/schemas/Usage" + }, + "stopReason": { + "$ref": "#/components/schemas/StopReason" + }, + "errorMessage": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "content", + "api", + "provider", + "model", + "usage", + "stopReason", + "timestamp" + ] + }, + "ThinkingContent": { + "type": "object", + "properties": { + "type": { + "const": "thinking" + }, + "thinking": { + "type": "string" + }, + "thinkingSignature": { + "type": "string" + }, + "redacted": { + "type": "boolean", + "description": "When true, the thinking content was redacted by safety filters. The opaque\nencrypted payload is stored in `thinkingSignature` so it can be passed back\nto the API for multi-turn continuity." + } + }, + "required": [ + "type", + "thinking" + ] + }, + "ToolCall": { + "type": "object", + "properties": { + "type": { + "const": "toolCall" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "arguments": { + "$ref": "#/components/schemas/Recordstringany" + }, + "thoughtSignature": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "name", + "arguments" + ] + }, + "Recordstringany": { + "type": "object", + "properties": {}, + "required": [], + "description": "Construct a type with a set of properties K of type T", + "additionalProperties": {} + }, + "Api": { + "type": "string" + }, + "AssistantMessageDiagnostic": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/DiagnosticErrorInfo" + }, + "details": { + "$ref": "#/components/schemas/Recordstringunknown" + } + }, + "required": [ + "type", + "timestamp" + ] + }, + "DiagnosticErrorInfo": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "type": "string" + }, + "code": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "message" + ] + }, + "Recordstringunknown": { + "type": "object", + "properties": {}, + "required": [], + "description": "Construct a type with a set of properties K of type T", + "additionalProperties": {} + }, + "Usage": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cacheRead": { + "type": "number" + }, + "cacheWrite": { + "type": "number" + }, + "totalTokens": { + "type": "number" + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cacheRead": { + "type": "number" + }, + "cacheWrite": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "input", + "output", + "cacheRead", + "cacheWrite", + "total" + ] + } + }, + "required": [ + "input", + "output", + "cacheRead", + "cacheWrite", + "totalTokens", + "cost" + ] + }, + "StopReason": { + "oneOf": [ + { + "const": "error" + }, + { + "const": "stop" + }, + { + "const": "length" + }, + { + "const": "toolUse" + }, + { + "const": "aborted" + } + ] + }, + "ToolResultMessageany": { + "type": "object", + "properties": { + "role": { + "const": "toolResult" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ImageContent" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "image": "#/components/schemas/ImageContent" + } + } + } + }, + "details": {}, + "isError": { + "type": "boolean" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "toolCallId", + "toolName", + "content", + "isError", + "timestamp" + ] + }, + "BashExecutionMessage": { + "type": "object", + "properties": { + "role": { + "const": "bashExecution" + }, + "command": { + "type": "string" + }, + "output": { + "type": "string" + }, + "exitCode": { + "type": "number" + }, + "cancelled": { + "type": "boolean" + }, + "truncated": { + "type": "boolean" + }, + "fullOutputPath": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "excludeFromContext": { + "type": "boolean" + } + }, + "required": [ + "role", + "command", + "output", + "cancelled", + "truncated", + "timestamp" + ] + }, + "CustomMessageunknown": { + "type": "object", + "properties": { + "role": { + "const": "custom" + }, + "customType": { + "type": "string" + }, + "content": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ImageContent" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "image": "#/components/schemas/ImageContent" + } + } + } + } + ] + }, + "display": { + "type": "boolean" + }, + "details": {}, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "customType", + "content", + "display", + "timestamp" + ] + }, + "BranchSummaryMessage": { + "type": "object", + "properties": { + "role": { + "const": "branchSummary" + }, + "summary": { + "type": "string" + }, + "fromId": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "summary", + "fromId", + "timestamp" + ] + }, + "CompactionSummaryMessage": { + "type": "object", + "properties": { + "role": { + "const": "compactionSummary" + }, + "summary": { + "type": "string" + }, + "tokensBefore": { + "type": "number" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "summary", + "tokensBefore", + "timestamp" + ] + }, + "ToolResultMessageany_o1": { + "type": "object", + "properties": { + "role": { + "const": "toolResult" + }, + "toolCallId": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/TextContent" + }, + { + "$ref": "#/components/schemas/ImageContent" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "text": "#/components/schemas/TextContent", + "image": "#/components/schemas/ImageContent" + } + } + } + }, + "details": {}, + "isError": { + "type": "boolean" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "role", + "toolCallId", + "toolName", + "content", + "isError", + "timestamp" + ] + }, + "AssistantMessageEvent": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "start" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "text_start" + }, + "contentIndex": { + "type": "number" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "text_delta" + }, + "contentIndex": { + "type": "number" + }, + "delta": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "delta", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "text_end" + }, + "contentIndex": { + "type": "number" + }, + "content": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "content", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "thinking_start" + }, + "contentIndex": { + "type": "number" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "thinking_delta" + }, + "contentIndex": { + "type": "number" + }, + "delta": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "delta", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "thinking_end" + }, + "contentIndex": { + "type": "number" + }, + "content": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "content", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "toolcall_start" + }, + "contentIndex": { + "type": "number" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "toolcall_delta" + }, + "contentIndex": { + "type": "number" + }, + "delta": { + "type": "string" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "delta", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "toolcall_end" + }, + "contentIndex": { + "type": "number" + }, + "toolCall": { + "$ref": "#/components/schemas/ToolCall" + }, + "partial": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "contentIndex", + "toolCall", + "partial" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "done" + }, + "reason": { + "oneOf": [ + { + "const": "stop" + }, + { + "const": "length" + }, + { + "const": "toolUse" + } + ] + }, + "message": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "reason", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "error" + }, + "reason": { + "oneOf": [ + { + "const": "error" + }, + { + "const": "aborted" + } + ] + }, + "error": { + "$ref": "#/components/schemas/AssistantMessage" + } + }, + "required": [ + "type", + "reason", + "error" + ] + } + ], + "description": "Event protocol for AssistantMessageEventStream.\n\nStreams should emit `start` before partial updates, then terminate with either:\n- `done` carrying the final successful AssistantMessage, or\n- `error` carrying the final AssistantMessage with stopReason \"error\" or \"aborted\"\n and errorMessage." + }, + "ThinkingLevel": { + "oneOf": [ + { + "const": "off" + }, + { + "const": "minimal" + }, + { + "const": "low" + }, + { + "const": "medium" + }, + { + "const": "high" + }, + { + "const": "xhigh" + } + ], + "description": "Thinking/reasoning level for models that support it.\nNote: \"xhigh\" is only supported by selected model families. Use model thinking-level metadata\nfrom" + }, + "CompactionResultunknown": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "firstKeptEntryId": { + "type": "string" + }, + "tokensBefore": { + "type": "number" + }, + "details": { + "description": "Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction)" + } + }, + "required": [ + "summary", + "firstKeptEntryId", + "tokensBefore" + ], + "description": "Result from compact() - SessionManager adds uuid/parentUuid when saving" + }, + "ExtensionUiRequest": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "select" + }, + "title": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title", + "options" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "confirm" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "input" + }, + "title": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "editor" + }, + "title": { + "type": "string" + }, + "prefill": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "notify" + }, + "message": { + "type": "string" + }, + "notifyType": { + "oneOf": [ + { + "const": "info" + }, + { + "const": "warning" + }, + { + "const": "error" + } + ] + } + }, + "required": [ + "type", + "id", + "method", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setStatus" + }, + "statusKey": { + "type": "string" + }, + "statusText": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "statusKey" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setWidget" + }, + "widgetKey": { + "type": "string" + }, + "widgetLines": { + "type": "array", + "items": { + "type": "string" + } + }, + "widgetPlacement": { + "oneOf": [ + { + "const": "aboveEditor" + }, + { + "const": "belowEditor" + } + ] + } + }, + "required": [ + "type", + "id", + "method", + "widgetKey" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "setTitle" + }, + "title": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "title" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "extension_ui_request" + }, + "id": { + "type": "string" + }, + "method": { + "const": "set_editor_text" + }, + "text": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "method", + "text" + ] + } + ] + } + } + }, + "schemas": [ + { + "$ref": "#/components/schemas/WireEvent" + }, + { + "$ref": "#/components/schemas/ExtensionUiRequest" + } + ] +} diff --git a/src/contract/eventValidation.ts b/src/contract/eventValidation.ts new file mode 100644 index 0000000..0b1f49f --- /dev/null +++ b/src/contract/eventValidation.ts @@ -0,0 +1,88 @@ +/** + * Runtime classification of outgoing SSE events against the published wire + * contract (`eventSchema.generated.json`, generated from pi's types via typia). + * + * This is a deliberately *shallow*, tolerant-reader check, not a deep validator. + * Deep validation of streaming events would false-alarm on legitimately partial + * messages (pi's `message_update` carries an in-progress `AssistantMessage` + * whose required fields fill in over the turn), so deep/strict validation lives + * in the test suite against curated complete fixtures instead. At runtime we + * only need the forward-compatibility signal: + * - `valid` — a `type` the published contract commits to. + * - `unknown-type` — a `type` not in the contract yet (pi added one): forward + * it and emit a soft signal — forward-compatible by design. + * - `invalid` — not an object / missing a string `type`. + * + * The known-type set is derived from the generated schema, so it can never drift + * from the contract. + */ +import { readFileSync } from "node:fs"; + +type JsonSchema = { + $ref?: string; + oneOf?: JsonSchema[]; + anyOf?: JsonSchema[]; + allOf?: JsonSchema[]; + properties?: { type?: { const?: unknown } }; +}; + +type GeneratedCollection = { + components: { schemas: Record }; + schemas: Array<{ $ref: string }>; +}; + +const generated = JSON.parse( + readFileSync(new URL("./eventSchema.generated.json", import.meta.url), "utf8"), +) as GeneratedCollection; + +const componentName = (ref: string): string => ref.split("/").pop() ?? ""; + +/** Walk the schema graph collecting every committed `type` discriminator const. */ +function collectTypeConsts( + schema: JsonSchema | undefined, + schemas: Record, + acc: Set, + seen = new Set(), +): void { + if (!schema || typeof schema !== "object") return; + if (schema.$ref) { + const name = componentName(schema.$ref); + if (seen.has(name)) return; + seen.add(name); + collectTypeConsts(schemas[name], schemas, acc, seen); + return; + } + for (const key of ["oneOf", "anyOf", "allOf"] as const) { + for (const member of schema[key] ?? []) collectTypeConsts(member, schemas, acc, seen); + } + const typeConst = schema.properties?.type?.const; + if (typeof typeConst === "string") acc.add(typeConst); +} + +const rootName = componentName(generated.schemas[0]?.$ref ?? ""); +const knownTypes = new Set(); +collectTypeConsts(generated.components.schemas[rootName], generated.components.schemas, knownTypes); + +/** Event `type`s the published wire contract commits to. Derived from the schema. */ +export const KNOWN_AGENT_SESSION_EVENT_TYPES: ReadonlySet = knownTypes; + +export type EventValidationResult = + | { status: "valid" } + | { status: "unknown-type"; type: string } + | { status: "invalid"; issues: string }; + +/** + * Classify an outgoing SSE event. Never throws and never mutates — callers + * forward the event regardless and use the result only for observability. + */ +export function validateAgentSessionEvent(event: unknown): EventValidationResult { + if (!event || typeof event !== "object") { + return { status: "invalid", issues: "event is not an object" }; + } + const type = (event as { type?: unknown }).type; + if (typeof type !== "string") { + return { status: "invalid", issues: "event is missing a string `type`" }; + } + if (!knownTypes.has(type)) return { status: "unknown-type", type }; + return { status: "valid" }; +} diff --git a/src/contract/openapi.ts b/src/contract/openapi.ts new file mode 100644 index 0000000..bb77f81 --- /dev/null +++ b/src/contract/openapi.ts @@ -0,0 +1,46 @@ +/** + * Build-time OpenAPI dump — emits openapi.json next to package.json so + * downstream consumers (eventx-backend) can run `openapi-typescript` + * against a stable file rather than having to spin up the live server + * during their build. + * + * Usage: `npm run openapi` (writes ./openapi.json). + * + * The document is built by the shared `buildOpenApiDocument` so it can't drift + * from what the live server publishes at `/openapi.json`; the only difference is + * that this host-agnostic dump omits the `servers` block. + */ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { createCredentialsApp } from "../http/credentialsRoutes.js"; +import { createProjectsApp } from "../http/projectsRoutes.js"; +import { createSessionsApp } from "../http/sessionsRoutes.js"; +import { ProjectRegistry } from "../runtime/projectRegistry.js"; +import type { ProjectRuntime } from "../runtime/projectRuntime.js"; +import { buildOpenApiDocument } from "./openapiEventSchema.js"; + +// We need a registry to construct the route apps, but we never actually call +// any methods during doc generation — the routes just reference handler +// functions whose signatures don't depend on state. Build it against a throwaway +// workspace so nothing touches the real filesystem layout. +const workspaceDir = mkdtempSync(resolve(tmpdir(), "agent-server-openapi-")); +const registry = await ProjectRegistry.create({ + workspaceDir, + logger: { log: () => {}, error: () => {} }, +}); +const stubResolver = async (): Promise => { + throw new Error("openapi stub resolver should never be invoked"); +}; // FIXME: What is this? + +const root = new OpenAPIHono(); +root.route("/v1", createCredentialsApp(registry.credentials)); +root.route("/v1", createProjectsApp(registry)); +root.route("/v1/projects/:projectId", createSessionsApp(stubResolver)); + +const doc = buildOpenApiDocument(root); + +const outPath = resolve(process.cwd(), "openapi.json"); +writeFileSync(outPath, `${JSON.stringify(doc, null, 2)}\n`); +console.log(`[openapi] wrote ${outPath}`); diff --git a/src/contract/openapiEventSchema.ts b/src/contract/openapiEventSchema.ts new file mode 100644 index 0000000..94270f8 --- /dev/null +++ b/src/contract/openapiEventSchema.ts @@ -0,0 +1,147 @@ +/** + * Merges the generated SSE wire-event schema (`eventSchema.generated.json`) into + * an OpenAPI document, and points the SSE endpoint's `text/event-stream` + * response at it. + * + * The REST surface is described by `@hono/zod-openapi` as usual; this adds the + * one schema that is generated from pi's TypeScript types (via typia) rather + * than authored as zod. `buildOpenApiDocument` below is the single entry point + * used by both the static `openapi` dump and the live `/openapi.json` handler, + * so the published metadata and merged schema can't drift between them. + * + * TODO: Long-term agent-server should define its own Anti-Corruption Layer to + * avoid depending on pi and propagating breaking changes + */ +import { readFileSync } from "node:fs"; +import type { OpenAPIHono } from "@hono/zod-openapi"; + +type GeneratedCollection = { + components?: { schemas?: Record }; + schemas?: Array<{ $ref: string }>; +}; + +const generated = JSON.parse( + readFileSync(new URL("./eventSchema.generated.json", import.meta.url), "utf8"), +) as GeneratedCollection; + +/** Component schemas generated from `WireEvent` (keyed by sanitized type name). */ +export const eventSchemaComponents: Record = generated.components?.schemas ?? {}; + +/** `$ref` of the root wire-event schema, e.g. `#/components/schemas/WireEvent`. */ +export const wireEventRef: string = generated.schemas?.[0]?.$ref ?? "#/components/schemas/WireEvent"; + +type OpenApiDoc = { + components?: { schemas?: Record }; + paths?: Record>; +}; + +/** + * REST responses that *forward* pi-shaped data agent-server doesn't author are + * validated permissively at runtime (zod `z.array(z.unknown())`, so a new pi + * field never 500s) but published with a precise item type so SDK consumers get + * the real shape instead of `unknown[]`. Maps each such response's `messages` / + * `requests` array to the canonical (typia-generated) component it carries. + */ +const forwardedArrayItemRefs: ReadonlyArray<{ + schema: string; + property: string; + itemRef: string; +}> = [ + { + schema: "SessionMessagesResponse", + property: "messages", + itemRef: "AgentMessage", + }, + { + schema: "PendingExtensionUiRequestsResponse", + property: "requests", + itemRef: "ExtensionUiRequest", + }, +]; + +type ArraySchema = { type?: string; items?: unknown }; +type ObjectSchema = { properties?: Record }; + +/** + * Point each forwarded array property at its canonical component `$ref`. Only + * rewrites when both the target response schema and the referenced component + * are present, so the doc stays valid even if a schema is renamed upstream. + */ +function pointForwardedArraysAtComponents(schemas: Record): void { + for (const { schema, property, itemRef } of forwardedArrayItemRefs) { + const objectSchema = schemas[schema] as ObjectSchema | undefined; + const arraySchema = objectSchema?.properties?.[property]; + if (!arraySchema || arraySchema.type !== "array") continue; + if (!(itemRef in schemas)) continue; + arraySchema.items = { $ref: `#/components/schemas/${itemRef}` }; + } +} + +/** + * Inject the generated wire-event components into `doc` and set every SSE + * (`text/event-stream`) 200-response schema to reference the root wire event. + * Mutates and returns `doc`. + */ +export function mergeEventSchema(doc: T): T { + const target = doc as OpenApiDoc; + target.components ??= {}; + target.components.schemas = { + ...(target.components.schemas ?? {}), + ...eventSchemaComponents, + }; + + pointForwardedArraysAtComponents(target.components.schemas); + + for (const pathItem of Object.values(target.paths ?? {})) { + for (const operation of Object.values(pathItem ?? {})) { + const content = ( + operation as { + responses?: { + "200"?: { content?: Record }; + }; + } + )?.responses?.["200"]?.content?.["text/event-stream"]; + if (content) content.schema = { $ref: wireEventRef }; + } + } + return doc; +} + +/** + * Canonical, version-bearing metadata for the published contract. Defined once + * here so the live server and the build-time dump can never disagree on title, + * version, or description. + */ +export const OPENAPI_INFO = { + title: "Appx Agent Server", + version: "0.1.0", + description: + "Pi-SDK-based agent orchestration. Shared auth/model state with explicit, persisted project-scoped session runtimes.", +} as const; + +export interface BuildOpenApiDocumentOptions { + /** + * Optional `servers` block. The live server advertises its own address; the + * build-time dump deliberately omits it so the committed spec stays + * host-agnostic for downstream codegen. + */ + servers?: Array<{ url: string; description?: string }>; +} + +/** + * Build the full OpenAPI 3.1 document for a mounted route tree: the canonical + * {@link OPENAPI_INFO}, the REST surface (from `@hono/zod-openapi`), and the + * merged typia-generated SSE wire-event schema. The single source of truth for + * the document shape — both `src/contract/openapi.ts` (static dump) and + * `server.ts` (`/openapi.json`) call it, so they can only differ in the + * explicit `servers` block. + */ +export function buildOpenApiDocument(root: OpenAPIHono, options: BuildOpenApiDocumentOptions = {}) { + return mergeEventSchema( + root.getOpenAPI31Document({ + openapi: "3.1.0", + info: { ...OPENAPI_INFO }, + ...(options.servers ? { servers: options.servers } : {}), + }), + ); +} diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts new file mode 100644 index 0000000..93ded2a --- /dev/null +++ b/src/contract/schemas.ts @@ -0,0 +1,352 @@ +/** + * Zod schemas for the agent-server REST API. + * + * These are the source of truth for: + * - request/response validation at runtime (via @hono/zod-openapi) + * - the OpenAPI 3.1 document published at /openapi.json + * - generated TypeScript types for consumers (eventx-backend uses + * `openapi-typescript` against the published openapi.json) + * + * The SSE `AgentSessionEvent` wire contract is NOT authored here. It is + * generated from pi's TypeScript types via typia (`scripts/genEventSchema.ts` + * → `eventSchema.generated.json`) and merged into the OpenAPI document by + * `openapiEventSchema.ts`, so consumers codegen the event/message types from + * the same `openapi.json` as the REST surface. pi stays the source of truth for + * its shapes; agent-server owns and versions the published contract. + */ +import { z } from "@hono/zod-openapi"; + +/** A row in the sessions list. Stable shape across all consuming apps. */ +export const SessionRowSchema = z + .object({ + id: z.string().openapi({ example: "01J9Z..." }), + createdAt: z.string().openapi({ + example: "2026-05-17T10:00:00.000Z", + description: "ISO-8601 UTC timestamp", + }), + firstMessage: z.string().openapi({ + description: "First user message; empty for never-prompted sessions.", + }), + messageCount: z.number().int().nonnegative(), + }) + .openapi("SessionRow"); + +export const ListSessionsResponseSchema = z + .object({ + sessions: z.array(SessionRowSchema), + }) + .openapi("ListSessionsResponse"); + +export const ThinkingLevelSchema = z + .enum(["off", "minimal", "low", "medium", "high", "xhigh"]) + .openapi("ThinkingLevel"); + +export const AgentModelRowSchema = z + .object({ + provider: z.string(), + id: z.string(), + name: z.string(), + api: z.string(), + reasoning: z.boolean(), + available: z.boolean(), + input: z.array(z.enum(["text", "image"])), + contextWindow: z.number().int().nonnegative(), + maxTokens: z.number().int().nonnegative(), + defaultThinkingLevel: ThinkingLevelSchema.optional(), + }) + .openapi("AgentModelRow"); + +export const ListModelsResponseSchema = z + .object({ + models: z.array(AgentModelRowSchema), + }) + .openapi("ListModelsResponse"); + +export const AuthProviderRowSchema = z + .object({ + provider: z.string(), + name: z.string(), + configured: z.boolean(), + credentialType: z.enum(["api_key", "oauth"]).optional(), + source: z + .enum(["stored", "runtime", "environment", "fallback", "models_json_key", "models_json_command"]) + .optional(), + label: z.string().optional(), + supportsApiKey: z.boolean(), + supportsSubscription: z.boolean(), + modelCount: z.number().int().nonnegative(), + availableModelCount: z.number().int().nonnegative(), + }) + .openapi("AuthProviderRow"); + +export const ListAuthProvidersResponseSchema = z + .object({ + providers: z.array(AuthProviderRowSchema), + }) + .openapi("ListAuthProvidersResponse"); + +export const SetProviderApiKeyRequestSchema = z + .object({ + key: z.string().min(1), + }) + .openapi("SetProviderApiKeyRequest"); + +export const OAuthFlowStateSchema = z + .object({ + id: z.string(), + provider: z.string(), + providerName: z.string(), + status: z.enum(["starting", "prompt", "auth", "waiting", "complete", "error", "cancelled"]), + authUrl: z.string().optional(), + instructions: z.string().optional(), + prompt: z + .object({ + message: z.string(), + placeholder: z.string().optional(), + allowEmpty: z.boolean().optional(), + }) + .optional(), + progress: z.array(z.string()), + error: z.string().optional(), + expiresAt: z.string(), + }) + .openapi("OAuthFlowState"); + +export const ContinueOAuthFlowRequestSchema = z + .object({ + value: z.string(), + }) + .openapi("ContinueOAuthFlowRequest"); + +export const OAuthFlowIdParamSchema = z.object({ + flowId: z + .string() + .min(1) + .openapi({ param: { name: "flowId", in: "path" } }), +}); + +export const CustomProviderModelSchema = z + .object({ + id: z.string().min(1), + name: z.string().optional(), + api: z.enum(["openai-completions", "openai-responses", "anthropic-messages"]).optional(), + reasoning: z.boolean().optional(), + thinkingLevelMap: z.record(z.union([z.string(), z.null()])).optional(), + input: z.array(z.enum(["text", "image"])).optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), + compat: z.record(z.unknown()).optional(), + }) + .openapi("CustomProviderModel"); + +export const CustomProviderRowSchema = z + .object({ + provider: z.string(), + name: z.string().optional(), + baseUrl: z.string().optional(), + api: z.enum(["openai-completions", "openai-responses", "anthropic-messages"]).optional(), + apiKeyConfigured: z.boolean(), + modelCount: z.number().int().nonnegative(), + models: z.array(CustomProviderModelSchema), + }) + .openapi("CustomProviderRow"); + +export const ListCustomProvidersResponseSchema = z + .object({ + providers: z.array(CustomProviderRowSchema), + }) + .openapi("ListCustomProvidersResponse"); + +export const UpsertCustomProviderRequestSchema = z + .object({ + provider: z + .string() + .min(1) + .regex(/^[a-zA-Z0-9_.:-]+$/), + name: z.string().optional(), + baseUrl: z.string().url(), + api: z.enum(["openai-completions", "openai-responses", "anthropic-messages"]), + apiKey: z.string().optional(), + models: z.array(CustomProviderModelSchema).min(1), + }) + .openapi("UpsertCustomProviderRequest"); + +export const SessionModelSettingsResponseSchema = z + .object({ + // Use a union (not `.nullable()`) so the OpenAPI emits + // `anyOf: [$ref, null]` → a clean `AgentModelRow | null` for consumers, + // rather than the `allOf: [$ref, {type:[object,null]}]` form that + // openapi-typescript renders as a broken `AgentModelRow & Record`. + model: z.union([AgentModelRowSchema, z.null()]), + thinkingLevel: ThinkingLevelSchema, + availableThinkingLevels: z.array(ThinkingLevelSchema), + supportsThinking: z.boolean(), + isStreaming: z.boolean(), + }) + .openapi("SessionModelSettingsResponse"); + +export const PatchSessionSettingsRequestSchema = z + .object({ + provider: z.string().min(1).optional(), + modelId: z.string().min(1).optional(), + thinkingLevel: ThinkingLevelSchema.optional(), + }) + .openapi("PatchSessionSettingsRequest"); + +export const CreateSessionResponseSchema = z + .object({ + id: z.string(), + createdAt: z.string(), + }) + .openapi("CreateSessionResponse"); + +/** + * Pi message shapes are rich (roles toolCall / toolResult, content parts, tool + * ids, etc.) and owned by pi, not this server. At **runtime** we forward + * whatever pi has persisted without re-validating it (`z.array(z.unknown())`), + * so a new pi message field never makes this endpoint 500. + * + * In the **published contract**, though, the array items are rewritten to + * `$ref` the canonical `AgentMessage` component (see `openapiEventSchema.ts`), + * so SDK consumers get the real message union instead of `unknown[]` — the + * client has to parse these, so the type is the whole point. + */ +export const SessionMessagesResponseSchema = z + .object({ + id: z.string(), + messages: z.array(z.unknown()).openapi({ + description: + "Pi-shaped message objects. Forwarded as-is at runtime; published as AgentMessage[] in the contract.", + }), + }) + .openapi("SessionMessagesResponse"); + +export const PromptRequestSchema = z + .object({ + text: z.string().min(1).openapi({ example: "find me events this weekend" }), + }) + .openapi("PromptRequest"); + +export const OkResponseSchema = z + .object({ + ok: z.literal(true), + }) + .openapi("OkResponse"); + +export const ExtensionUiRequestIdParamSchema = z.object({ + requestId: z + .string() + .min(1) + .openapi({ param: { name: "requestId", in: "path" } }), +}); + +export const ExtensionUiResponseRequestSchema = z + .union([ + z.object({ value: z.string() }), + z.object({ confirmed: z.boolean() }), + z.object({ cancelled: z.literal(true) }), + ]) + .openapi("ExtensionUiResponseRequest"); + +export const PendingExtensionUiRequestsResponseSchema = z + .object({ + // Runtime-permissive (forwarded pi RPC events); published as ExtensionUiRequest[] + // in the contract via $ref rewrite in openapiEventSchema.ts. + requests: z.array(z.unknown()).openapi({ + description: + "Pending extension UI request events. Forwarded as-is at runtime; published as ExtensionUiRequest[] in the contract.", + }), + }) + .openapi("PendingExtensionUiRequestsResponse"); + +export const ErrorResponseSchema = z + .object({ + error: z.string(), + }) + .openapi("ErrorResponse"); + +export const HealthResponseSchema = z + .object({ + ok: z.literal(true), + service: z.literal("agent-server"), + time: z.string(), + channels: z.record(z.number()).openapi({ + description: "Map of SSE channel name → current subscriber count.", + }), + }) + .openapi("HealthResponse"); + +export const SessionIdParamSchema = z.object({ + projectId: z + .string() + .min(1) + .openapi({ param: { name: "projectId", in: "path" } }), + id: z + .string() + .min(1) + .openapi({ param: { name: "id", in: "path" } }), +}); + +/** + * Path params for project-scoped session collection routes (`/sessions`, + * `/sessions` POST) that don't carry a session `{id}`. The `{projectId}` is + * supplied by the mount prefix (`/v1/projects/{projectId}`); declaring it here + * keeps the published contract complete so generated clients can substitute it. + */ +export const ProjectScopeParamSchema = z.object({ + projectId: z + .string() + .min(1) + .openapi({ param: { name: "projectId", in: "path" } }), +}); + +/** Path param for project lifecycle routes (`/v1/projects/{id}`). */ +export const ProjectIdParamSchema = z.object({ + id: z + .string() + .min(1) + .openapi({ param: { name: "id", in: "path" } }), +}); + +/** Body for `POST /v1/projects`. Name-only — the id/dir are derived server-side. */ +export const CreateProjectRequestSchema = z + .object({ + name: z.string().min(1).openapi({ + example: "My Cool App", + description: "Human-facing project name. Slugified into the immutable id and directory name.", + }), + }) + .openapi("CreateProjectRequest"); + +/** Public view of a project returned by the lifecycle routes. */ +export const ProjectInfoSchema = z + .object({ + id: z.string().openapi({ + example: "my-cool-app", + description: "Immutable slug; registry key, route param, and directory name.", + }), + name: z.string().openapi({ example: "My Cool App" }), + projectDir: z.string().openapi({ + example: "/workspace/my-cool-app", + description: "Absolute working directory under WORKSPACE_DIR.", + }), + createdAt: z.string().openapi({ + example: "2026-06-03T10:00:00.000Z", + description: "ISO-8601 UTC timestamp", + }), + }) + .openapi("ProjectInfo"); + +export const ListProjectsResponseSchema = z + .object({ + projects: z.array(ProjectInfoSchema), + }) + .openapi("ListProjectsResponse"); + +export const ProviderParamSchema = z.object({ + provider: z + .string() + .min(1) + .regex(/^[a-zA-Z0-9_.:-]+$/) + .openapi({ param: { name: "provider", in: "path" } }), +}); diff --git a/src/contract/wireEvents.ts b/src/contract/wireEvents.ts new file mode 100644 index 0000000..b6150f9 --- /dev/null +++ b/src/contract/wireEvents.ts @@ -0,0 +1,29 @@ +/** + * The agent-server SSE *wire event* type. + * + * This composes pi's `AgentSessionEvent` (the events pi emits, already a clean, + * canonical, well-typed union) with the two events agent-server itself injects + * onto the same stream: `extension_ui_request` and `extension_error`. + * + * `WireEvent` is the single source of truth for the SSE contract. We do NOT + * hand-author a parallel schema: `scripts/genEventSchema.ts` runs typia over + * this type to emit the OpenAPI 3.1 schema (`eventSchema.generated.json`), which + * is merged into `openapi.json` so every consumer codegens from it. pi stays the + * source of truth for its own shapes; agent-server owns and versions the + * published contract. + */ +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; +import type { ExtensionUiRequest } from "../shared/extensionUi.js"; + +/** Emitted when a pi extension handler throws; surfaced to the UI for visibility. */ +export interface ExtensionErrorEvent { + type: "extension_error"; + extensionPath: string; + /** The pi lifecycle event during which the error occurred (e.g. "session_start"). */ + event?: string; + error: string; + stack?: string; +} + +/** Every JSON event agent-server forwards on `GET …/sessions/{id}/events`. */ +export type WireEvent = AgentSessionEvent | ExtensionUiRequest | ExtensionErrorEvent; diff --git a/src/credentials/credentialsService.ts b/src/credentials/credentialsService.ts new file mode 100644 index 0000000..85a05ae --- /dev/null +++ b/src/credentials/credentialsService.ts @@ -0,0 +1,545 @@ +/** + * AgentCredentialsService — process-global credential state. + * + * Owns AuthStorage, ModelRegistry, models.json CRUD, and the in-memory + * OAuth subscription flow state machine. ProjectRuntime instances hold a + * reference for read-only projections (listModels, modelRow used in + * session settings). Routes for /v1/auth/* and /v1/custom/* call this + * directly via createCredentialsApp. + */ +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import type { AuthStorage, CreateAgentSessionOptions, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { clampThinkingLevelForModel, type ThinkingLevel } from "../shared/thinking.js"; + +type SessionModel = NonNullable; +const CUSTOM_PROVIDER_APIS = ["openai-completions", "openai-responses", "anthropic-messages"] as const; + +export type AgentCustomProviderApi = (typeof CUSTOM_PROVIDER_APIS)[number]; + +export type AgentModelRow = { + provider: string; + id: string; + name: string; + api: string; + reasoning: boolean; + available: boolean; + input: Array<"text" | "image">; + contextWindow: number; + maxTokens: number; + defaultThinkingLevel?: ThinkingLevel; +}; + +export type AgentAuthProviderRow = { + provider: string; + name: string; + configured: boolean; + credentialType?: "api_key" | "oauth"; + source?: "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command"; + label?: string; + supportsApiKey: boolean; + supportsSubscription: boolean; + modelCount: number; + availableModelCount: number; +}; + +export type AgentAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type AgentCustomProviderModel = { + id: string; + name?: string; + api?: AgentCustomProviderApi; + reasoning?: boolean; + thinkingLevelMap?: Partial>; + input?: Array<"text" | "image">; + contextWindow?: number; + maxTokens?: number; + compat?: Record; +}; + +export type AgentCustomProviderRow = { + provider: string; + name?: string; + baseUrl?: string; + api?: AgentCustomProviderApi; + apiKeyConfigured: boolean; + modelCount: number; + models: AgentCustomProviderModel[]; +}; + +export type UpsertCustomProviderRequest = { + provider: string; + name?: string; + baseUrl: string; + api: AgentCustomProviderApi; + apiKey?: string; + models: AgentCustomProviderModel[]; +}; + +export type AgentOAuthFlowState = { + id: string; + provider: string; + providerName: string; + status: "starting" | "prompt" | "auth" | "waiting" | "complete" | "error" | "cancelled"; + authUrl?: string; + instructions?: string; + prompt?: AgentAuthPrompt; + progress: string[]; + error?: string; + expiresAt: string; +}; + +type PendingOAuthFlow = AgentOAuthFlowState & { + version: number; + abortController: AbortController; + promptResolve?: (value: string) => void; + promptReject?: (error: Error) => void; + manualResolve?: (value: string) => void; + manualReject?: (error: Error) => void; + waiters: Array<(state: AgentOAuthFlowState) => void>; + cleanupTimer?: ReturnType; +}; + +export type AgentCredentialsServiceConfig = { + authStorage: AuthStorage; + modelRegistry: ModelRegistry; + modelsJsonPath: string; + defaultModelProvider?: string; + defaultModelId?: string; + defaultThinkingLevel?: ThinkingLevel; + modelThinkingDefaults?: Record; + logger?: Pick; +}; + +export class AgentCredentialsService { + private readonly authStorage: AuthStorage; + private readonly modelRegistry: ModelRegistry; + private readonly modelsJsonPath: string; + private readonly logger: Pick; + private readonly defaultModelProvider: string | undefined; + private readonly defaultModelId: string | undefined; + private readonly defaultThinkingLevel: ThinkingLevel | undefined; + private readonly modelThinkingDefaults: Record; + private readonly pendingOAuthFlows = new Map(); + + constructor(config: AgentCredentialsServiceConfig) { + this.authStorage = config.authStorage; + this.modelRegistry = config.modelRegistry; + this.modelsJsonPath = config.modelsJsonPath; + this.logger = config.logger ?? console; + this.defaultModelProvider = config.defaultModelProvider; + this.defaultModelId = config.defaultModelId; + this.defaultThinkingLevel = config.defaultThinkingLevel; + this.modelThinkingDefaults = config.modelThinkingDefaults ?? {}; + } + + private modelKey(model: Pick): string { + return `${model.provider}/${model.id}`; + } + + private assertProviderId(provider: string): void { + if (!/^[a-zA-Z0-9_.:-]+$/.test(provider)) { + throw new Error("invalid provider id"); + } + } + + private customProviderApi(value: unknown): AgentCustomProviderApi | undefined { + return CUSTOM_PROVIDER_APIS.includes(value as AgentCustomProviderApi) + ? (value as AgentCustomProviderApi) + : undefined; + } + + private readModelsJson(): { providers: Record> } { + if (!existsSync(this.modelsJsonPath)) return { providers: {} }; + const parsed = JSON.parse(readFileSync(this.modelsJsonPath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("models.json must be a JSON object"); + } + const record = parsed as Record; + const providers = record.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) { + return { ...record, providers: {} } as { providers: Record> }; + } + return { ...record, providers } as { providers: Record> }; + } + + private writeModelsJson(config: { providers: Record> }): void { + writeFileSync(this.modelsJsonPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + chmodSync(this.modelsJsonPath, 0o600); + } + + defaultThinkingForModel(model: SessionModel): ThinkingLevel | undefined { + const configured = this.modelThinkingDefaults[this.modelKey(model)] ?? this.defaultThinkingLevel; + return configured ? clampThinkingLevelForModel(model, configured) : undefined; + } + + modelRow(model: SessionModel): AgentModelRow { + return { + provider: model.provider, + id: model.id, + name: model.name, + api: model.api, + reasoning: model.reasoning, + available: this.modelRegistry.hasConfiguredAuth(model), + input: [...model.input], + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + defaultThinkingLevel: this.defaultThinkingForModel(model), + }; + } + + listModels(): AgentModelRow[] { + return this.modelRegistry + .getAll() + .map((model) => this.modelRow(model as SessionModel)) + .sort( + (a, b) => + Number(b.available) - Number(a.available) || + a.provider.localeCompare(b.provider) || + a.name.localeCompare(b.name), + ); + } + + listAuthProviders(): AgentAuthProviderRow[] { + const byProvider = new Map(); + for (const model of this.listModels()) { + const current = byProvider.get(model.provider) ?? { modelCount: 0, availableModelCount: 0 }; + current.modelCount += 1; + if (model.available) current.availableModelCount += 1; + byProvider.set(model.provider, current); + } + const oauthProviderIds = new Set(this.authStorage.getOAuthProviders().map((provider) => provider.id)); + for (const provider of oauthProviderIds) { + if (!byProvider.has(provider)) { + byProvider.set(provider, { modelCount: 0, availableModelCount: 0 }); + } + } + + return [...byProvider.entries()] + .map(([provider, counts]) => { + const status = this.modelRegistry.getProviderAuthStatus(provider); + const credential = this.authStorage.get(provider); + return { + provider, + name: this.modelRegistry.getProviderDisplayName(provider), + configured: status.configured || status.source !== undefined, + credentialType: credential?.type, + source: status.source, + label: status.label, + supportsApiKey: counts.modelCount > 0, + supportsSubscription: oauthProviderIds.has(provider), + ...counts, + }; + }) + .sort( + (a, b) => + Number(b.configured) - Number(a.configured) || + b.availableModelCount - a.availableModelCount || + a.provider.localeCompare(b.provider), + ); + } + + setProviderApiKey(provider: string, key: string): void { + this.assertProviderId(provider); + const trimmed = key.trim(); + if (!trimmed) throw new Error("key is required"); + this.authStorage.set(provider, { type: "api_key", key: trimmed }); + this.modelRegistry.refresh(); + } + + removeProviderCredential(provider: string): void { + this.assertProviderId(provider); + this.authStorage.remove(provider); + this.modelRegistry.refresh(); + } + + private oauthFlowState(flow: PendingOAuthFlow): AgentOAuthFlowState { + return { + id: flow.id, + provider: flow.provider, + providerName: flow.providerName, + status: flow.status, + authUrl: flow.authUrl, + instructions: flow.instructions, + prompt: flow.prompt, + progress: [...flow.progress], + error: flow.error, + expiresAt: flow.expiresAt, + }; + } + + private updateOAuthFlow(flow: PendingOAuthFlow, patch: Partial): void { + Object.assign(flow, patch); + flow.version += 1; + const state = this.oauthFlowState(flow); + const waiters = flow.waiters.splice(0); + for (const waiter of waiters) waiter(state); + } + + private scheduleOAuthFlowCleanup(flow: PendingOAuthFlow, delayMs = 10 * 60 * 1000): void { + if (flow.cleanupTimer) clearTimeout(flow.cleanupTimer); + flow.cleanupTimer = setTimeout(() => { + this.pendingOAuthFlows.delete(flow.id); + }, delayMs); + flow.cleanupTimer.unref?.(); + } + + private activeOAuthFlowForProvider(provider: string): PendingOAuthFlow | undefined { + const now = Date.now(); + for (const flow of this.pendingOAuthFlows.values()) { + if (flow.provider !== provider) continue; + if (["complete", "error", "cancelled"].includes(flow.status)) continue; + if (Date.parse(flow.expiresAt) <= now) continue; + return flow; + } + return undefined; + } + + private oauthLoginErrorMessage(providerName: string, error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("EADDRINUSE")) { + return `${providerName} login callback is already running on its local port. Finish or cancel the existing login, then try again.`; + } + return message; + } + + private waitForOAuthFlowUpdate( + flow: PendingOAuthFlow, + version: number, + timeoutMs = 15_000, + ): Promise { + if (flow.version !== version) return Promise.resolve(this.oauthFlowState(flow)); + if (["complete", "error", "cancelled"].includes(flow.status)) { + return Promise.resolve(this.oauthFlowState(flow)); + } + + return new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(this.oauthFlowState(flow)); + }, timeoutMs); + flow.waiters.push((state) => { + clearTimeout(timer); + resolve(state); + }); + }); + } + + async startProviderSubscriptionLogin(provider: string): Promise { + this.assertProviderId(provider); + const oauthProvider = this.authStorage.getOAuthProviders().find((entry) => entry.id === provider); + if (!oauthProvider) throw new Error(`provider ${provider} does not support subscription auth`); + + const activeFlow = this.activeOAuthFlowForProvider(provider); + if (activeFlow) return this.oauthFlowState(activeFlow); + + const flow: PendingOAuthFlow = { + id: randomUUID(), + provider, + providerName: oauthProvider.name, + status: "starting", + progress: [], + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + version: 0, + abortController: new AbortController(), + waiters: [], + }; + this.pendingOAuthFlows.set(flow.id, flow); + this.scheduleOAuthFlowCleanup(flow); + + const loginPromise = this.authStorage.login(provider, { + onAuth: (info) => { + this.updateOAuthFlow(flow, { + status: "auth", + authUrl: info.url, + instructions: info.instructions, + prompt: undefined, + }); + }, + onPrompt: (prompt) => + new Promise((resolve, reject) => { + flow.promptResolve = resolve; + flow.promptReject = reject; + this.updateOAuthFlow(flow, { + status: "prompt", + prompt: { + message: prompt.message, + placeholder: prompt.placeholder, + allowEmpty: prompt.allowEmpty, + }, + }); + }), + onProgress: (message) => { + this.updateOAuthFlow(flow, { progress: [...flow.progress, message] }); + }, + onManualCodeInput: () => + new Promise((resolve, reject) => { + flow.manualResolve = resolve; + flow.manualReject = reject; + }), + signal: flow.abortController.signal, + }); + + void loginPromise + .then(() => { + this.modelRegistry.refresh(); + this.updateOAuthFlow(flow, { + status: "complete", + prompt: undefined, + authUrl: undefined, + instructions: undefined, + progress: [...flow.progress, "Credentials saved."], + }); + this.scheduleOAuthFlowCleanup(flow, 60_000); + }) + .catch((error: unknown) => { + this.updateOAuthFlow(flow, { + status: flow.status === "cancelled" ? "cancelled" : "error", + error: this.oauthLoginErrorMessage(flow.providerName, error), + }); + this.scheduleOAuthFlowCleanup(flow, 60_000); + }); + + return this.waitForOAuthFlowUpdate(flow, 0); + } + + async continueProviderSubscriptionLogin(id: string, value: string): Promise { + const flow = this.pendingOAuthFlows.get(id); + if (!flow) throw new Error("subscription auth flow not found"); + const trimmed = value.trim(); + + if (flow.promptResolve) { + if (!trimmed && !flow.prompt?.allowEmpty) throw new Error("value is required"); + const resolve = flow.promptResolve; + flow.promptResolve = undefined; + flow.promptReject = undefined; + this.updateOAuthFlow(flow, { status: "waiting", prompt: undefined }); + const waitVersion = flow.version; + resolve(value); + return this.waitForOAuthFlowUpdate(flow, waitVersion); + } + + if (flow.manualResolve) { + if (!trimmed) throw new Error("redirect URL or authorization code is required"); + const resolve = flow.manualResolve; + flow.manualResolve = undefined; + flow.manualReject = undefined; + this.updateOAuthFlow(flow, { status: "waiting", prompt: undefined }); + const waitVersion = flow.version; + resolve(trimmed); + return this.waitForOAuthFlowUpdate(flow, waitVersion); + } + + return this.oauthFlowState(flow); + } + + getProviderSubscriptionLogin(id: string): AgentOAuthFlowState | undefined { + const flow = this.pendingOAuthFlows.get(id); + return flow ? this.oauthFlowState(flow) : undefined; + } + + cancelProviderSubscriptionLogin(id: string): AgentOAuthFlowState | undefined { + const flow = this.pendingOAuthFlows.get(id); + if (!flow) return undefined; + flow.abortController.abort(); + flow.promptReject?.(new Error("Login cancelled")); + flow.manualReject?.(new Error("Login cancelled")); + this.updateOAuthFlow(flow, { status: "cancelled", error: "Login cancelled" }); + this.scheduleOAuthFlowCleanup(flow, 60_000); + return this.oauthFlowState(flow); + } + + listCustomProviders(): AgentCustomProviderRow[] { + const config = this.readModelsJson(); + return Object.entries(config.providers) + .filter(([, providerConfig]) => Array.isArray(providerConfig.models)) + .map(([provider, providerConfig]) => { + const models = (providerConfig.models as unknown[]) + .filter( + (model): model is Record => + Boolean(model) && typeof model === "object" && typeof (model as { id?: unknown }).id === "string", + ) + .map((model) => ({ + ...model, + id: String(model.id), + name: typeof model.name === "string" ? model.name : undefined, + api: this.customProviderApi(model.api), + reasoning: typeof model.reasoning === "boolean" ? model.reasoning : undefined, + input: Array.isArray(model.input) + ? model.input.filter((entry): entry is "text" | "image" => entry === "text" || entry === "image") + : undefined, + contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : undefined, + maxTokens: typeof model.maxTokens === "number" ? model.maxTokens : undefined, + thinkingLevelMap: + model.thinkingLevelMap && + typeof model.thinkingLevelMap === "object" && + !Array.isArray(model.thinkingLevelMap) + ? (model.thinkingLevelMap as Partial>) + : undefined, + compat: + model.compat && typeof model.compat === "object" && !Array.isArray(model.compat) + ? (model.compat as Record) + : undefined, + })); + return { + provider, + name: typeof providerConfig.name === "string" ? providerConfig.name : undefined, + baseUrl: typeof providerConfig.baseUrl === "string" ? providerConfig.baseUrl : undefined, + api: this.customProviderApi(providerConfig.api), + apiKeyConfigured: typeof providerConfig.apiKey === "string" && providerConfig.apiKey.trim().length > 0, + modelCount: models.length, + models, + }; + }) + .sort((a, b) => a.provider.localeCompare(b.provider)); + } + + upsertCustomProvider(input: UpsertCustomProviderRequest): AgentCustomProviderRow { + this.assertProviderId(input.provider); + const baseUrl = input.baseUrl.trim(); + if (!baseUrl) throw new Error("baseUrl is required"); + const parsedUrl = new URL(baseUrl); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("baseUrl must use http or https"); + } + const models = input.models.map((model) => ({ ...model, id: model.id.trim() })); + if (models.some((model) => !model.id)) throw new Error("model id is required"); + if (!models.length) throw new Error("at least one model is required"); + + const config = this.readModelsJson(); + const existing = config.providers[input.provider] ?? {}; + const apiKey = input.apiKey?.trim() || (typeof existing.apiKey === "string" ? existing.apiKey : ""); + if (!apiKey) throw new Error("apiKey is required for custom providers"); + + config.providers[input.provider] = { + name: input.name?.trim() || input.provider, + baseUrl, + api: input.api, + apiKey, + models: models.map((model) => ({ + ...model, + name: model.name?.trim() || model.id, + api: model.api, + input: model.input ?? ["text"], + contextWindow: model.contextWindow ?? 128000, + maxTokens: model.maxTokens ?? 16384, + reasoning: model.reasoning ?? false, + })), + }; + + this.writeModelsJson(config); + this.modelRegistry.refresh(); + return this.listCustomProviders().find((provider) => provider.provider === input.provider)!; + } + + removeCustomProvider(provider: string): void { + this.assertProviderId(provider); + const config = this.readModelsJson(); + delete config.providers[provider]; + this.writeModelsJson(config); + this.modelRegistry.refresh(); + } +} diff --git a/src/http/credentialsRoutes.ts b/src/http/credentialsRoutes.ts new file mode 100644 index 0000000..98d6273 --- /dev/null +++ b/src/http/credentialsRoutes.ts @@ -0,0 +1,441 @@ +/** + * HTTP routes for credentials, models, and provider auth — a Hono OpenAPIHono + * app exposing the org-shared AgentCredentialsService. + * + * Surface (mounted by the server under /v1): + * GET /sessions/models list selectable models + * GET /auth/providers list provider auth status without secrets + * PUT /auth/providers/{provider}/api-key store a provider API key + * DELETE /auth/providers/{provider} remove a stored credential + * POST /auth/providers/{provider}/subscription/start + * start a Pi subscription OAuth flow + * GET /auth/subscription/{flowId} read OAuth flow state + * POST /auth/subscription/{flowId}/continue continue OAuth input + * DELETE /auth/subscription/{flowId} cancel a pending flow + * GET /custom/providers list custom models.json providers + * PUT /custom/providers create/update a custom provider + * DELETE /custom/providers/{provider} remove a custom provider + * GET /healthz liveness + channel stats // FIXME: Do we need healthz here? + * + * Session routes live in sessionsRoutes.ts; project-lifecycle routes in + * projectsRoutes.ts. + */ +import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; +import type { Context } from "hono"; +import { + ContinueOAuthFlowRequestSchema, + CustomProviderRowSchema, + ErrorResponseSchema, + HealthResponseSchema, + ListAuthProvidersResponseSchema, + ListCustomProvidersResponseSchema, + ListModelsResponseSchema, + OAuthFlowIdParamSchema, + OAuthFlowStateSchema, + OkResponseSchema, + ProviderParamSchema, + SetProviderApiKeyRequestSchema, + UpsertCustomProviderRequestSchema, +} from "../contract/schemas.js"; +import type { AgentCredentialsService } from "../credentials/credentialsService.js"; +import { channelStats } from "./sseBroker.js"; + +export type AgentCredentialsResolver = (c: Context) => AgentCredentialsService | Promise; +export type CreateCredentialsAppOptions = { + /** Liveness endpoint for this mounted API. Default true. */ + healthRoute?: boolean; +}; + +function isCredentialsResolver( + credentials: AgentCredentialsService | AgentCredentialsResolver, +): credentials is AgentCredentialsResolver { + return typeof credentials === "function"; +} + +/** + * Build the Hono app exposing credential management routes. Versioning is + * the caller's job (server.ts mounts this under /v1). + */ +export function createCredentialsApp( + credentials: AgentCredentialsService | AgentCredentialsResolver, + options: CreateCredentialsAppOptions = {}, +): OpenAPIHono { + const app = new OpenAPIHono(); + const healthRoute = options.healthRoute ?? true; + const getCredentials = (c: Context) => (isCredentialsResolver(credentials) ? credentials(c) : credentials); + + // ── GET /sessions/models ──────────────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/sessions/models", + operationId: "listModels", + tags: ["models"], + summary: "List models known to this runtime, including unavailable ones for diagnostics.", + responses: { + 200: { + description: "Known models.", + content: { + "application/json": { schema: ListModelsResponseSchema }, + }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + return c.json({ models: credentials.listModels() }, 200); + }, + ); + + // ── GET /auth/providers ───────────────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/auth/providers", + operationId: "listAuthProviders", + tags: ["auth"], + summary: "List non-secret provider auth status for the runtime.", + responses: { + 200: { + description: "Known providers and whether each has configured auth.", + content: { + "application/json": { schema: ListAuthProvidersResponseSchema }, + }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + return c.json({ providers: credentials.listAuthProviders() }, 200); + }, + ); + + // ── PUT /auth/providers/{provider}/api-key ────────────────────── + app.openapi( + createRoute({ + method: "put", + path: "/auth/providers/{provider}/api-key", + operationId: "setProviderApiKey", + tags: ["auth"], + summary: "Store an API key for a provider in Pi auth storage.", + request: { + params: ProviderParamSchema, + body: { + required: true, + content: { + "application/json": { schema: SetProviderApiKeyRequestSchema }, + }, + }, + }, + responses: { + 200: { + description: "Credential stored.", + content: { "application/json": { schema: OkResponseSchema } }, + }, + 400: { + description: "Invalid provider or key.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + const { provider } = c.req.valid("param"); + const { key } = c.req.valid("json"); + try { + credentials.setProviderApiKey(provider, key); + return c.json({ ok: true as const }, 200); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 400); + } + }, + ); + + // ── DELETE /auth/providers/{provider} ─────────────────────────── + app.openapi( + createRoute({ + method: "delete", + path: "/auth/providers/{provider}", + operationId: "removeProviderCredential", + tags: ["auth"], + summary: "Remove a stored provider credential from Pi auth storage.", + request: { params: ProviderParamSchema }, + responses: { + 200: { + description: "Credential removed if it existed.", + content: { "application/json": { schema: OkResponseSchema } }, + }, + 400: { + description: "Invalid provider.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + const { provider } = c.req.valid("param"); + try { + credentials.removeProviderCredential(provider); + return c.json({ ok: true as const }, 200); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 400); + } + }, + ); + + // ── POST /auth/providers/{provider}/subscription/start ────────── + app.openapi( + createRoute({ + method: "post", + path: "/auth/providers/{provider}/subscription/start", + operationId: "startProviderSubscriptionLogin", + tags: ["auth"], + summary: "Start a Pi subscription OAuth login flow.", + request: { params: ProviderParamSchema }, + responses: { + 200: { + description: "Current flow state. Continue if a prompt or pasted redirect is required.", + content: { "application/json": { schema: OAuthFlowStateSchema } }, + }, + 400: { + description: "Provider does not support subscription auth.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + const { provider } = c.req.valid("param"); + try { + return c.json(await credentials.startProviderSubscriptionLogin(provider), 200); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 400); + } + }, + ); + + // ── GET /auth/subscription/{flowId} ────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/auth/subscription/{flowId}", + operationId: "getProviderSubscriptionLogin", + tags: ["auth"], + summary: "Return subscription login flow state.", + request: { params: OAuthFlowIdParamSchema }, + responses: { + 200: { + description: "Current flow state.", + content: { "application/json": { schema: OAuthFlowStateSchema } }, + }, + 404: { + description: "Flow not found.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + const { flowId } = c.req.valid("param"); + const state = credentials.getProviderSubscriptionLogin(flowId); + if (!state) return c.json({ error: "subscription auth flow not found" }, 404); + return c.json(state, 200); + }, + ); + + // ── POST /auth/subscription/{flowId}/continue ──────────────────── + app.openapi( + createRoute({ + method: "post", + path: "/auth/subscription/{flowId}/continue", + operationId: "continueProviderSubscriptionLogin", + tags: ["auth"], + summary: "Continue a subscription login flow with prompt input or pasted redirect URL.", + request: { + params: OAuthFlowIdParamSchema, + body: { + required: true, + content: { + "application/json": { schema: ContinueOAuthFlowRequestSchema }, + }, + }, + }, + responses: { + 200: { + description: "Updated flow state.", + content: { "application/json": { schema: OAuthFlowStateSchema } }, + }, + 400: { + description: "Invalid input.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 404: { + description: "Flow not found.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + const { flowId } = c.req.valid("param"); + const { value } = c.req.valid("json"); + try { + return c.json(await credentials.continueProviderSubscriptionLogin(flowId, value), 200); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.json({ error: message }, message.includes("not found") ? 404 : 400); + } + }, + ); + + // ── DELETE /auth/subscription/{flowId} ─────────────────────────── + app.openapi( + createRoute({ + method: "delete", + path: "/auth/subscription/{flowId}", + operationId: "cancelProviderSubscriptionLogin", + tags: ["auth"], + summary: "Cancel a pending subscription login flow.", + request: { params: OAuthFlowIdParamSchema }, + responses: { + 200: { + description: "Cancelled flow state.", + content: { "application/json": { schema: OAuthFlowStateSchema } }, + }, + 404: { + description: "Flow not found.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + const { flowId } = c.req.valid("param"); + const state = credentials.cancelProviderSubscriptionLogin(flowId); + if (!state) return c.json({ error: "subscription auth flow not found" }, 404); + return c.json(state, 200); + }, + ); + + // ── GET /custom/providers ──────────────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/custom/providers", + operationId: "listCustomProviders", + tags: ["models"], + summary: "List custom models.json providers without secret values.", + responses: { + 200: { + description: "Custom providers.", + content: { + "application/json": { schema: ListCustomProvidersResponseSchema }, + }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + return c.json({ providers: credentials.listCustomProviders() }, 200); + }, + ); + + // ── PUT /custom/providers ──────────────────────────────────────── + app.openapi( + createRoute({ + method: "put", + path: "/custom/providers", + operationId: "upsertCustomProvider", + tags: ["models"], + summary: "Create or update a custom Pi provider in models.json.", + request: { + body: { + required: true, + content: { + "application/json": { schema: UpsertCustomProviderRequestSchema }, + }, + }, + }, + responses: { + 200: { + description: "Custom provider saved.", + content: { "application/json": { schema: CustomProviderRowSchema } }, + }, + 400: { + description: "Invalid custom provider config.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + try { + return c.json(credentials.upsertCustomProvider(c.req.valid("json")), 200); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 400); + } + }, + ); + + // ── DELETE /custom/providers/{provider} ────────────────────────── + app.openapi( + createRoute({ + method: "delete", + path: "/custom/providers/{provider}", + operationId: "removeCustomProvider", + tags: ["models"], + summary: "Remove a custom Pi provider from models.json.", + request: { params: ProviderParamSchema }, + responses: { + 200: { + description: "Custom provider removed if it existed.", + content: { "application/json": { schema: OkResponseSchema } }, + }, + 400: { + description: "Invalid provider.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const credentials = await getCredentials(c); + const { provider } = c.req.valid("param"); + try { + credentials.removeCustomProvider(provider); + return c.json({ ok: true as const }, 200); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 400); + } + }, + ); + + // ── GET /healthz ───────────────────────────────────────────────── + if (healthRoute) + app.openapi( + createRoute({ + method: "get", + path: "/healthz", + operationId: "healthCheck", + tags: ["meta"], + summary: "Liveness + diagnostic counters.", + responses: { + 200: { + description: "OK.", + content: { "application/json": { schema: HealthResponseSchema } }, + }, + }, + }), + (c) => + c.json( + { + ok: true as const, + service: "agent-server" as const, + time: new Date().toISOString(), + channels: channelStats(), + }, + 200, + ), + ); + + return app; +} diff --git a/src/http/projectsRoutes.ts b/src/http/projectsRoutes.ts new file mode 100644 index 0000000..bde6f08 --- /dev/null +++ b/src/http/projectsRoutes.ts @@ -0,0 +1,147 @@ +/** + * HTTP routes for project lifecycle management. + * + * Surface (mounted by the server under `/v1`): + * POST /projects create-or-get a project (idempotent on name) + * GET /projects list registered projects + * GET /projects/{id} get one project's metadata + * DELETE /projects/{id} remove a project (runtime + metadata + on-disk dirs) + * + * These replace the old header-driven, lazily-created project model: a project + * is now an explicit, persisted resource owned by the ProjectRegistry. Session + * routes (mounted separately at `/v1/projects/{id}/sessions...`) only resolve an + * already-registered runtime by id. See + * docs/architecture/project-lifecycle-and-workspace-layout.md. + */ +import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; +import { + CreateProjectRequestSchema, + ErrorResponseSchema, + ListProjectsResponseSchema, + OkResponseSchema, + ProjectIdParamSchema, + ProjectInfoSchema, +} from "../contract/schemas.js"; +import { InvalidProjectNameError, type ProjectRegistry } from "../runtime/projectRegistry.js"; + +/** + * Build the Hono app exposing project lifecycle routes. Versioning/prefixing is + * the caller's job (server.ts mounts this under `/v1`). + */ +export function createProjectsApp(registry: ProjectRegistry): OpenAPIHono { + const app = new OpenAPIHono(); + + // ── POST /projects ─────────────────────────────────────────────── + app.openapi( + createRoute({ + method: "post", + path: "/projects", + operationId: "createProject", + tags: ["projects"], + summary: "Create a project, or return the existing one (idempotent on name).", + request: { + body: { + required: true, + content: { "application/json": { schema: CreateProjectRequestSchema } }, + }, + }, + responses: { + 200: { + description: "The created or already-existing project.", + content: { "application/json": { schema: ProjectInfoSchema } }, + }, + 400: { + description: "Name does not yield a valid project id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + (c) => { + const { name } = c.req.valid("json"); + try { + return c.json(registry.createProject({ name }), 200); + } catch (err) { + if (err instanceof InvalidProjectNameError) { + return c.json({ error: err.message }, 400); + } + throw err; + } + }, + ); + + // ── GET /projects ──────────────────────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/projects", + operationId: "listProjects", + tags: ["projects"], + summary: "List registered projects, newest first.", + responses: { + 200: { + description: "Registered projects.", + content: { "application/json": { schema: ListProjectsResponseSchema } }, + }, + }, + }), + (c) => c.json({ projects: registry.listProjects() }, 200), + ); + + // ── GET /projects/{id} ─────────────────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/projects/{id}", + operationId: "getProject", + tags: ["projects"], + summary: "Get a single project's metadata.", + request: { params: ProjectIdParamSchema }, + responses: { + 200: { + description: "Project metadata.", + content: { "application/json": { schema: ProjectInfoSchema } }, + }, + 404: { + description: "Unknown project id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + (c) => { + const { id } = c.req.valid("param"); + const project = registry.getProject(id); + if (!project) return c.json({ error: "project not found" }, 404); + return c.json(project, 200); + }, + ); + + // ── DELETE /projects/{id} ──────────────────────────────────────── + app.openapi( + createRoute({ + method: "delete", + path: "/projects/{id}", + operationId: "deleteProject", + tags: ["projects"], + summary: "Remove a project: evict runtime, drop metadata, delete working dir + transcripts.", + request: { params: ProjectIdParamSchema }, + responses: { + 200: { + description: "Project removed if it existed.", + content: { "application/json": { schema: OkResponseSchema } }, + }, + 404: { + description: "Unknown project id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + (c) => { + const { id } = c.req.valid("param"); + const removed = registry.removeProject(id); + if (!removed) return c.json({ error: "project not found" }, 404); + return c.json({ ok: true } as const, 200); + }, + ); + + return app; +} diff --git a/src/http/sessionsRoutes.ts b/src/http/sessionsRoutes.ts new file mode 100644 index 0000000..149938e --- /dev/null +++ b/src/http/sessionsRoutes.ts @@ -0,0 +1,529 @@ +/** + * HTTP routes for agent sessions — a Hono OpenAPIHono app exposing a + * ProjectRuntime's sessions over REST + SSE. + * + * Surface (mounted by the server under /v1/projects/:projectId): + * GET /sessions list sessions (disk + in-memory) + * POST /sessions create new session + * GET /sessions/{id} persisted message history + * DELETE /sessions/{id} delete a session + its history + * GET /sessions/{id}/settings current model/thinking settings + * PATCH /sessions/{id}/settings switch model/thinking while idle + * GET /sessions/{id}/events SSE stream of pi AgentSessionEvents + * GET /sessions/{id}/extension-ui + * list pending extension UI requests + * POST /sessions/{id}/extension-ui/{requestId}/response + * answer extension UI request + * POST /sessions/{id}/prompt send a user prompt + * POST /sessions/{id}/abort abort in-flight run + * + * The SSE endpoint is *not* declared via @hono/zod-openapi — its response + * is a long-lived stream, not a JSON body, and the OpenAPI tooling for + * SSE is weak. We register a plain Hono GET for it and document it in the + * spec manually below so consumers see the path. + * + * Credential/model and project-lifecycle routes live in their own files + * (credentialsRoutes.ts, projectsRoutes.ts). + */ +import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; +import type { Context } from "hono"; +import { streamSSE } from "hono/streaming"; +import { + CreateSessionResponseSchema, + ErrorResponseSchema, + ExtensionUiRequestIdParamSchema, + ExtensionUiResponseRequestSchema, + ListSessionsResponseSchema, + OkResponseSchema, + PatchSessionSettingsRequestSchema, + PendingExtensionUiRequestsResponseSchema, + ProjectScopeParamSchema, + PromptRequestSchema, + SessionIdParamSchema, + SessionMessagesResponseSchema, + SessionModelSettingsResponseSchema, +} from "../contract/schemas.js"; +import type { ProjectRuntime } from "../runtime/projectRuntime.js"; +import { subscribe } from "./sseBroker.js"; + +/** Heartbeat cadence for SSE keepalive. Keeps proxies / LBs from closing idle streams. */ +const SSE_HEARTBEAT_MS = 15_000; + +export type ProjectRuntimeResolver = (c: Context) => ProjectRuntime | Promise; +export type CreateSessionsAppOptions = Record; + +function isRuntimeResolver(runtime: ProjectRuntime | ProjectRuntimeResolver): runtime is ProjectRuntimeResolver { + return typeof runtime === "function"; +} + +function settingsErrorStatus(err: unknown): 400 | 404 | 409 | 500 { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("not found")) return 404; + if (message.includes("running")) return 409; + if (message.includes("No API key")) return 400; + return 500; +} + +/** + * Build the Hono app exposing a project's session routes. Versioning/prefixing + * is the caller's job (server.ts mounts this under /v1/projects/:projectId). + */ +export function createSessionsApp(runtime: ProjectRuntime | ProjectRuntimeResolver): OpenAPIHono { + const app = new OpenAPIHono(); + const getRuntime = (c: Context) => (isRuntimeResolver(runtime) ? runtime(c) : runtime); + + // ── GET /sessions ──────────────────────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/sessions", + operationId: "listSessions", + tags: ["sessions"], + summary: "List sessions (persisted + in-memory not yet flushed).", + request: { params: ProjectScopeParamSchema }, + responses: { + 200: { + description: "Sessions, newest first.", + content: { + "application/json": { schema: ListSessionsResponseSchema }, + }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const sessions = await runtime.listSessions(); + return c.json({ sessions }, 200); + }, + ); + + // ── POST /sessions ─────────────────────────────────────────────── + app.openapi( + createRoute({ + method: "post", + path: "/sessions", + operationId: "createSession", + tags: ["sessions"], + summary: "Create a new session.", + request: { params: ProjectScopeParamSchema }, + responses: { + 200: { + description: "Newly created session metadata.", + content: { + "application/json": { schema: CreateSessionResponseSchema }, + }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const session = await runtime.createNewSession(); + return c.json({ id: session.sessionId, createdAt: session.boundAt }, 200); + }, + ); + + // ── GET /sessions/{id}/settings ───────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/sessions/{id}/settings", + operationId: "getSessionSettings", + tags: ["models"], + summary: "Return the active model/thinking settings for a session.", + request: { params: SessionIdParamSchema }, + responses: { + 200: { + description: "Session model settings.", + content: { + "application/json": { schema: SessionModelSettingsResponseSchema }, + }, + }, + 404: { + description: "Unknown session id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const { id } = c.req.valid("param"); + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + return c.json(session.getModelSettings(), 200); + }, + ); + + // ── PATCH /sessions/{id}/settings ──────────────────────────────── + app.openapi( + createRoute({ + method: "patch", + path: "/sessions/{id}/settings", + operationId: "updateSessionSettings", + tags: ["models"], + summary: "Switch model and/or thinking level while a session is idle.", + request: { + params: SessionIdParamSchema, + body: { + required: true, + content: { + "application/json": { schema: PatchSessionSettingsRequestSchema }, + }, + }, + }, + responses: { + 200: { + description: "Effective session model settings.", + content: { + "application/json": { schema: SessionModelSettingsResponseSchema }, + }, + }, + 400: { + description: "Invalid settings body.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 404: { + description: "Unknown session id or model id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 409: { + description: "Session is currently running.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 500: { + description: "Unexpected settings update error.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const { id } = c.req.valid("param"); + const body = c.req.valid("json"); + const hasProvider = Boolean(body.provider); + const hasModelId = Boolean(body.modelId); + if (hasProvider !== hasModelId) { + return c.json({ error: "provider and modelId must be supplied together" }, 400); + } + if (!body.provider && !body.thinkingLevel) { + return c.json({ error: "provider/modelId or thinkingLevel is required" }, 400); + } + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + try { + const settings = await session.updateModelSettings(body); + return c.json(settings, 200); + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, settingsErrorStatus(err)); + } + }, + ); + + // ── GET /sessions/{id} ─────────────────────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/sessions/{id}", + operationId: "getSessionMessages", + tags: ["sessions"], + summary: "Persisted message history for a session.", + request: { params: SessionIdParamSchema }, + responses: { + 200: { + description: "Messages for the session.", + content: { + "application/json": { schema: SessionMessagesResponseSchema }, + }, + }, + 404: { + description: "Unknown session id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const { id } = c.req.valid("param"); + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + return c.json({ id, messages: session.getMessages() }, 200); + }, + ); + + // ── DELETE /sessions/{id} ────────────────────────────── + app.openapi( + createRoute({ + method: "delete", + path: "/sessions/{id}", + operationId: "deleteSession", + tags: ["sessions"], + summary: "Permanently delete a session and its persisted history.", + request: { params: SessionIdParamSchema }, + responses: { + 200: { + description: "Session deleted.", + content: { "application/json": { schema: OkResponseSchema } }, + }, + 404: { + description: "Unknown session id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const { id } = c.req.valid("param"); + const deleted = await runtime.deleteSession(id); + if (!deleted) return c.json({ error: "session not found" }, 404); + return c.json({ ok: true } as const, 200); + }, + ); + + // ── GET /sessions/{id}/extension-ui ───────────────────────────── + app.openapi( + createRoute({ + method: "get", + path: "/sessions/{id}/extension-ui", + operationId: "listExtensionUiRequests", + tags: ["extensions"], + summary: "List pending extension UI requests for a session.", + request: { params: SessionIdParamSchema }, + responses: { + 200: { + description: "Pending extension UI request events.", + content: { + "application/json": { + schema: PendingExtensionUiRequestsResponseSchema, + }, + }, + }, + 404: { + description: "Unknown session id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const { id } = c.req.valid("param"); + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + return c.json({ requests: session.pendingExtensionUiRequests() }, 200); + }, + ); + + // ── POST /sessions/{id}/extension-ui/{requestId}/response ─────── + app.openapi( + createRoute({ + method: "post", + path: "/sessions/{id}/extension-ui/{requestId}/response", + operationId: "respondExtensionUiRequest", + tags: ["extensions"], + summary: "Resolve a pending extension UI request.", + request: { + params: SessionIdParamSchema.merge(ExtensionUiRequestIdParamSchema), + body: { + required: true, + content: { + "application/json": { schema: ExtensionUiResponseRequestSchema }, + }, + }, + }, + responses: { + 200: { + description: "Extension UI response accepted.", + content: { "application/json": { schema: OkResponseSchema } }, + }, + 404: { + description: "Unknown session id or request id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const { id, requestId } = c.req.valid("param"); + const body = c.req.valid("json"); + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + const ok = session.resolveExtensionUiRequest(requestId, body); + if (!ok) return c.json({ error: "extension UI request not found" }, 404); + return c.json({ ok: true } as const, 200); + }, + ); + + // ── POST /sessions/{id}/prompt ─────────────────────────────────── + app.openapi( + createRoute({ + method: "post", + path: "/sessions/{id}/prompt", + operationId: "sendPrompt", + tags: ["sessions"], + summary: "Send a user prompt. Events flow over the SSE stream.", + request: { + params: SessionIdParamSchema, + body: { + required: true, + content: { "application/json": { schema: PromptRequestSchema } }, + }, + }, + responses: { + 200: { + description: "Prompt accepted and queued.", + content: { "application/json": { schema: OkResponseSchema } }, + }, + 404: { + description: "Unknown session id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const { id } = c.req.valid("param"); + const { text } = c.req.valid("json"); + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + // Fire-and-forget: events flow over SSE, errors surface there too. + session.sendPrompt(text).catch((err) => { + console.error("[agent-server] prompt failed:", err); + }); + return c.json({ ok: true } as const, 200); + }, + ); + + // ── POST /sessions/{id}/abort ──────────────────────────────────── + app.openapi( + createRoute({ + method: "post", + path: "/sessions/{id}/abort", + operationId: "abortSession", + tags: ["sessions"], + summary: "Abort the in-flight run on a session. No-op if idle.", + request: { params: SessionIdParamSchema }, + responses: { + 200: { + description: "Abort accepted (or no-op if session was idle).", + content: { "application/json": { schema: OkResponseSchema } }, + }, + 404: { + description: "Unknown session id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const runtime = await getRuntime(c); + const { id } = c.req.valid("param"); + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + try { + await session.abort(); + return c.json({ ok: true } as const, 200); + } catch (err) { + return c.json({ error: String(err) }, 404); + } + }, + ); + + // ── GET /sessions/{id}/events (SSE — not in OpenAPI body schemas) ── + // + // Documented in the OpenAPI registry as text/event-stream so consumers + // see the path, but no JSON schema is generated for it. The frontend + // consumes this via `EventSource`; eventx-backend pipes the upstream + // stream byte-for-byte. + app.openAPIRegistry.registerPath({ + // pure documentation for reference + method: "get", + path: "/sessions/{id}/events", + operationId: "streamSessionEvents", + tags: ["sessions"], + summary: "Server-Sent Events stream of pi AgentSessionEvents for the session.", + description: + "Long-lived `text/event-stream`. Each `data:` line carries one JSON " + + "`AgentSessionEvent` (see the `AgentSessionEvent` schema). Non-JSON " + + "lines occur too: an initial `connected to ` line and periodic " + + "`heartbeat` keepalive events, both of which consumers ignore. The " + + "event payload is validated against this contract server-side before " + + "being forwarded.", + request: { params: SessionIdParamSchema }, + responses: { + 200: { + description: "SSE stream. Each `data:` line is a JSON-encoded AgentSessionEvent.", + content: { + // Resolves to the generated wire-event schema; the component is + // merged into the document by mergeEventSchema() (openapiEventSchema.ts). + "text/event-stream": { + schema: { $ref: "#/components/schemas/WireEvent" } as never, + }, + }, + }, + 404: { + description: "Unknown session id.", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }); + + // actual handler for the SSE endpoint + app.get("/sessions/:id/events", async (c) => { + const runtime = await getRuntime(c); + const id = c.req.param("id"); + const session = await runtime.getSession(id); + if (!session) return c.json({ error: "session not found" }, 404); + + return streamSSE(c, async (stream) => { + // Per-subscriber queue + wakeup. Listener pushes; loop drains. + const queue: string[] = []; + let wake: (() => void) | null = null; + const wait = () => + new Promise((resolve) => { + wake = resolve; + }); + + const unsubscribe = subscribe(id, (event) => { + queue.push(JSON.stringify(event)); + if (wake) { + wake(); + wake = null; + } + }); + + stream.onAbort(() => { + unsubscribe(); + if (wake) { + wake(); + wake = null; + } + }); + + await stream.writeSSE({ data: `connected to ${id}` }); + for (const request of session.pendingExtensionUiRequests()) { + await stream.writeSSE({ data: JSON.stringify(request) }); + } + + let lastBeat = Date.now(); + while (!stream.aborted) { + if (queue.length === 0) { + const timer = new Promise((resolve) => setTimeout(resolve, SSE_HEARTBEAT_MS)); + await Promise.race([wait(), timer]); + } + if (stream.aborted) break; + + while (queue.length > 0) { + await stream.writeSSE({ data: queue.shift()! }); + } + + if (Date.now() - lastBeat >= SSE_HEARTBEAT_MS) { + // Named event — frontend EventSource ignores it (no listener), + // but the bytes keep proxies happy. + await stream.writeSSE({ event: "heartbeat", data: "ping" }); + lastBeat = Date.now(); + } + } + + unsubscribe(); + }); + }); + + return app; +} diff --git a/src/sseBroker.ts b/src/http/sseBroker.ts similarity index 53% rename from src/sseBroker.ts rename to src/http/sseBroker.ts index 234d938..f5b8335 100644 --- a/src/sseBroker.ts +++ b/src/http/sseBroker.ts @@ -15,8 +15,13 @@ type Listener = (event: unknown) => void; +// one set of listener callbacks per channel (per session) +// each tab/device that opens /sessions/{id}/events adds its own listener callback to the Set +// three tabs watching the same session = three callbacks in that set const channels = new Map>(); +// FIXME: Should we create a SSEBroker class or rename functions to sseSubscribe? Currently too generic name + /** * Register a listener on the given channel. Returns an unsubscribe * function. The listener is invoked synchronously from `publish`; if it @@ -24,16 +29,16 @@ const channels = new Map>(); * tear down the rest. */ export function subscribe(channel: string, listener: Listener): () => void { - let listeners = channels.get(channel); - if (!listeners) { - listeners = new Set(); - channels.set(channel, listeners); - } - listeners.add(listener); - return () => { - listeners.delete(listener); - if (listeners.size === 0) channels.delete(channel); - }; + let listeners = channels.get(channel); + if (!listeners) { + listeners = new Set(); + channels.set(channel, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) channels.delete(channel); + }; } /** @@ -42,24 +47,24 @@ export function subscribe(channel: string, listener: Listener): () => void { * connecting). */ export function publish(channel: string, event: unknown): void { - const listeners = channels.get(channel); - if (!listeners || listeners.size === 0) return; - for (const l of listeners) { - try { - l(event); - } catch (err) { - // Don't tear down the broker — other subscribers on this - // channel are still viable. But a thrown listener is a real - // bug surface (e.g. JSON.stringify on a non-serialisable - // event, or future listener code), so log loudly. - console.error(`[sse] listener on channel '${channel}' threw:`, err); - } - } + const listeners = channels.get(channel); + if (!listeners || listeners.size === 0) return; + for (const l of listeners) { + try { + l(event); + } catch (err) { + // Don't tear down the broker — other subscribers on this + // channel are still viable. But a thrown listener is a real + // bug surface (e.g. JSON.stringify on a non-serialisable + // event, or future listener code), so log loudly. + console.error(`[sse] listener on channel '${channel}' threw:`, err); + } + } } /** Diagnostic: subscriber count per channel. */ export function channelStats(): Record { - const out: Record = {}; - for (const [k, v] of channels) out[k] = v.size; - return out; + const out: Record = {}; + for (const [k, v] of channels) out[k] = v.size; + return out; } diff --git a/src/index.ts b/src/index.ts index aa9b145..22ab286 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,11 +8,49 @@ * larger Node process (for tests, or for hosts that prefer to mount * our routes inside their own Hono app). */ -export { AgentRuntime } from "./runtime.js"; -export type { AgentRuntimeConfig, SessionRow } from "./runtime.js"; -export { createSessionsApp } from "./routes.js"; -export { subscribe, publish, channelStats } from "./sseBroker.js"; + export type { AgentSession, AgentSessionEvent, + AgentSessionRuntimeDiagnostic, + AgentSessionServices, } from "@earendil-works/pi-coding-agent"; +export type { ServerConfig } from "./config.js"; +export type { AgentCredentialsServiceConfig } from "./credentials/credentialsService.js"; +export { AgentCredentialsService } from "./credentials/credentialsService.js"; +export type { + AgentCredentialsResolver, + CreateCredentialsAppOptions, +} from "./http/credentialsRoutes.js"; +export { createCredentialsApp } from "./http/credentialsRoutes.js"; +export { createProjectsApp } from "./http/projectsRoutes.js"; +export type { + CreateSessionsAppOptions, + ProjectRuntimeResolver, +} from "./http/sessionsRoutes.js"; +export { createSessionsApp } from "./http/sessionsRoutes.js"; +export { channelStats, publish, subscribe } from "./http/sseBroker.js"; +export { litellmRuntimeConfig, logLiteLlmStartupConfig, resolveLiteLlmConfig } from "./providers/litellm.js"; +export type { + ProjectInfo, + ProjectRegistryConfig, +} from "./runtime/projectRegistry.js"; +export { InvalidProjectNameError, ProjectRegistry } from "./runtime/projectRegistry.js"; +export type { + AgentAuthProviderRow, + AgentCustomProviderApi, + AgentCustomProviderModel, + AgentCustomProviderRow, + AgentModelRow, + AgentOAuthFlowState, + ProjectRuntimeConfig, + SessionRow, + ThinkingLevel, +} from "./runtime/projectRuntime.js"; +export { ProjectRuntime } from "./runtime/projectRuntime.js"; +export type { SessionModelSettings } from "./runtime/projectSession.js"; +export { ProjectSession } from "./runtime/projectSession.js"; +export type { ProjectRecord } from "./runtime/projectStore.js"; +export { ProjectStore } from "./runtime/projectStore.js"; +export type { ExtensionUiRequest, ExtensionUiResponse } from "./shared/extensionUi.js"; +export { clampThinkingLevelForModel, supportedThinkingLevelsForModel, THINKING_LEVELS } from "./shared/thinking.js"; diff --git a/src/openapi.ts b/src/openapi.ts deleted file mode 100644 index 966a83f..0000000 --- a/src/openapi.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Build-time OpenAPI dump — emits openapi.json next to package.json so - * downstream consumers (eventx-backend) can run `openapi-typescript` - * against a stable file rather than having to spin up the live server - * during their build. - * - * Usage: `npm run openapi` (writes ./openapi.json). - * - * This script must mirror server.ts's mounting structure so the doc - * matches what the live server publishes. Keep them in sync. - */ -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { OpenAPIHono } from "@hono/zod-openapi"; -import { AgentRuntime } from "./runtime.js"; -import { createSessionsApp } from "./routes.js"; - -// We need an AgentRuntime to construct the routes app, but we never -// actually call any runtime methods during doc generation — the routes -// just reference handler functions whose signatures don't depend on -// runtime state. Use a stub projectDir so AgentRuntime's constructor -// passes its sanity checks. -const stubProjectDir = resolve(process.cwd()); -const runtime = new AgentRuntime({ - projectDir: stubProjectDir, - sessionsDir: resolve(stubProjectDir, ".tmp-openapi-sessions"), - // no agentsFile so we don't require a real .pi/AGENTS.md for codegen -}); - -const root = new OpenAPIHono(); -root.route("/v1", createSessionsApp(runtime)); - -const doc = root.getOpenAPI31Document({ - openapi: "3.1.0", - info: { - title: "Appx Agent Server", - version: "0.1.0", - description: - "Pi-SDK-based agent orchestration. Single-tenant per process; one instance per Appx app.", - }, -}); - -const outPath = resolve(process.cwd(), "openapi.json"); -writeFileSync(outPath, `${JSON.stringify(doc, null, 2)}\n`); -console.log(`[openapi] wrote ${outPath}`); diff --git a/src/providers/litellm.ts b/src/providers/litellm.ts new file mode 100644 index 0000000..084083e --- /dev/null +++ b/src/providers/litellm.ts @@ -0,0 +1,479 @@ +/** + * LiteLLM runtime wiring for the embedded Pi SDK. + * + * SDK session model selection happens before extension session_start handlers, + * so dynamic provider registration has to happen directly on ProjectRuntime's + * ModelRegistry before createAgentSession(). + */ +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { ProjectRuntimeConfig } from "../runtime/projectRuntime.js"; +import { + clampThinkingLevelForModel, + THINKING_LEVELS as SHARED_THINKING_LEVELS, + supportedThinkingLevelsForModel, + type ThinkingLevel, +} from "../shared/thinking.js"; + +type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages"; + +type LiteLlmModel = { + id: string; + name?: string; + baseUrl?: string; + api?: ProviderApi; + reasoning?: boolean; + thinkingLevelMap?: Partial>; + /** Session thinking default to use when this model is the selected default. */ + defaultThinkingLevel?: ThinkingLevel; + input?: Array<"text" | "image">; + contextWindow?: number; + maxTokens?: number; + cost?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + }; + /** Model-level OpenAI-compatible provider quirks. Overrides LITELLM_COMPAT_JSON. */ + compat?: Record; +}; + +type ProviderConfig = Parameters[1]; +type ProviderModel = NonNullable[number]; + +type ResolvedLiteLlmConfig = { + baseUrl: string; + providerApi: ProviderApi; + providerCompat: Record; + models: ProviderModel[]; + defaultModelId: string; + defaultModel: ProviderModel; + /** Global fallback thinking level from LITELLM_DEFAULT_THINKING. */ + globalThinkingLevel: ThinkingLevel | undefined; + /** Effective thinking level for the selected default model. */ + thinkingLevel: ThinkingLevel | undefined; + /** Per-model defaults keyed as `${provider}/${modelId}` for ProjectRuntime. */ + modelThinkingDefaults: Record; +}; + +type NormalisedLiteLlmModel = { + model: ProviderModel; + defaultThinkingLevel?: ThinkingLevel; +}; + +const LOG_PREFIX = "[agent-server-litellm]"; +const apiValues = new Set(["openai-completions", "openai-responses", "anthropic-messages"]); +const thinkingValues = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]); + +const DEFAULT_CONTEXT_WINDOW = 128_000; +const DEFAULT_MAX_TOKENS = 16_384; + +const conservativeOpenAiCompat = { + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsUsageInStreaming: false, + maxTokensField: "max_tokens", +}; + +const gpt55ThinkingLevelMap: Partial> = { + off: "none", + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", +}; + +const deepSeekV4ThinkingLevelMap: Partial> = { + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: "max", +}; + +let cachedConfig: ResolvedLiteLlmConfig | null | undefined; +let startupConfigLogged = false; + +function parseApi(raw: string | undefined, fallback: ProviderApi): ProviderApi { + const value = raw?.trim(); + if (!value) return fallback; + if (apiValues.has(value as ProviderApi)) return value as ProviderApi; + console.warn(`${LOG_PREFIX} unsupported API ${value}; using ${fallback}`); + return fallback; +} + +function parseBool(raw: string | undefined, fallback: boolean): boolean { + if (raw === undefined) return fallback; + const value = raw.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(value)) return true; + if (["0", "false", "no", "off"].includes(value)) return false; + return fallback; +} + +function parsePositiveInt(raw: string | undefined, fallback: number): number { + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : fallback; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function parseJsonObject(raw: string, name: string): Record { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) throw new Error(`${name} must be a JSON object`); + return parsed; +} + +function parseCompat(): Record { + const raw = process.env.LITELLM_COMPAT_JSON?.trim(); + if (!raw) return { ...conservativeOpenAiCompat }; + return { ...conservativeOpenAiCompat, ...parseJsonObject(raw, "LITELLM_COMPAT_JSON") }; +} + +function modelPreset(id: string): Partial { + if (id === "openai/gpt-5.5") { + return { + name: "GPT 5.5 (Codex)", + api: "openai-responses", + reasoning: true, + thinkingLevelMap: gpt55ThinkingLevelMap, + defaultThinkingLevel: "xhigh", + compat: { + thinkingFormat: "openai", + supportsReasoningEffort: true, + maxTokensField: "max_output_tokens", + supportsPromptCacheKey: true, + promptCacheRetention: "24h", + }, + }; + } + if (id === "deepseek/deepseek-v4-pro" || id === "deepseek/deepseek-v4-flash") { + return { + api: "openai-completions", + reasoning: true, + thinkingLevelMap: deepSeekV4ThinkingLevelMap, + defaultThinkingLevel: "high", + compat: { + thinkingFormat: "deepseek", + maxTokensField: "max_tokens", + }, + }; + } + return {}; +} + +function parseThinkingLevelValue(raw: unknown, name: string, warnOnly = false): ThinkingLevel | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "string") { + const message = `${LOG_PREFIX} ${name} must be a string`; + if (warnOnly) { + console.warn(`${message}; Pi default will be used`); + return undefined; + } + throw new Error(`${name} must be one of ${SHARED_THINKING_LEVELS.join(", ")}`); + } + const value = raw.trim(); + if (!value) return undefined; + if (thinkingValues.has(value as ThinkingLevel)) return value as ThinkingLevel; + const message = `${LOG_PREFIX} unsupported ${name} ${value}`; + if (warnOnly) { + console.warn(`${message}; Pi default will be used`); + return undefined; + } + throw new Error(`${name} must be one of ${SHARED_THINKING_LEVELS.join(", ")}`); +} + +function modelKey(modelId: string): string { + return `litellm/${modelId}`; +} + +function modelFromId(id: string): LiteLlmModel { + return { + id, + name: id, + input: ["text"], + contextWindow: parsePositiveInt(process.env.LITELLM_CONTEXT_WINDOW, DEFAULT_CONTEXT_WINDOW), + maxTokens: parsePositiveInt(process.env.LITELLM_MAX_TOKENS, DEFAULT_MAX_TOKENS), + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; +} + +function modelCompat( + model: LiteLlmModel, + providerCompat: Record, + presetCompat: Record | undefined, +): Record { + if (model.compat !== undefined && !isRecord(model.compat)) { + throw new Error(`LITELLM_MODELS_JSON model ${model.id || ""} compat must be a JSON object`); + } + return { ...providerCompat, ...(presetCompat ?? {}), ...(model.compat ?? {}) }; +} + +function normaliseThinkingLevelMap( + modelId: string, + map: LiteLlmModel["thinkingLevelMap"], +): LiteLlmModel["thinkingLevelMap"] { + if (map === undefined) return undefined; + if (!isRecord(map)) throw new Error(`LITELLM_MODELS_JSON model ${modelId} thinkingLevelMap must be a JSON object`); + const result: Partial> = {}; + for (const [key, value] of Object.entries(map)) { + if (!thinkingValues.has(key as ThinkingLevel)) { + throw new Error(`LITELLM_MODELS_JSON model ${modelId} has unsupported thinkingLevelMap key ${key}`); + } + if (value !== null && typeof value !== "string") { + throw new Error(`LITELLM_MODELS_JSON model ${modelId} thinkingLevelMap.${key} must be a string or null`); + } + result[key as ThinkingLevel] = value; + } + return result; +} + +function mergeThinkingLevelMaps( + modelId: string, + presetMap: LiteLlmModel["thinkingLevelMap"], + modelMap: LiteLlmModel["thinkingLevelMap"], +): LiteLlmModel["thinkingLevelMap"] { + const normalisedPreset = normaliseThinkingLevelMap(modelId, presetMap); + const normalisedModel = normaliseThinkingLevelMap(modelId, modelMap); + if (!normalisedPreset && !normalisedModel) return undefined; + return { ...(normalisedPreset ?? {}), ...(normalisedModel ?? {}) }; +} + +function normaliseModel(model: LiteLlmModel, providerCompat: Record): NormalisedLiteLlmModel { + if (!isRecord(model)) throw new Error("LITELLM_MODELS_JSON entries must be JSON objects"); + if (!model.id?.trim()) throw new Error("LiteLLM model entry is missing id"); + const id = model.id.trim(); + const base = modelFromId(id); + const preset = modelPreset(id); + const fallbackApi = parseApi(process.env.LITELLM_API, "openai-completions"); + const fallbackReasoning = parseBool(process.env.LITELLM_REASONING, false); + const { defaultThinkingLevel: presetDefaultThinkingLevel, ...presetForProvider } = preset; + const { defaultThinkingLevel: modelDefaultThinkingLevel, ...modelForProvider } = model; + const thinkingLevelMap = mergeThinkingLevelMaps(id, preset.thinkingLevelMap, model.thinkingLevelMap); + const defaultThinkingLevel = modelDefaultThinkingLevel ?? presetDefaultThinkingLevel; + const providerModel: ProviderModel = { + ...base, + ...presetForProvider, + ...modelForProvider, + id, + name: model.name ?? preset.name ?? id, + api: model.api ? parseApi(model.api, fallbackApi) : (preset.api ?? fallbackApi), + reasoning: model.reasoning ?? preset.reasoning ?? fallbackReasoning, + thinkingLevelMap, + input: model.input ?? preset.input ?? base.input!, + contextWindow: model.contextWindow ?? preset.contextWindow ?? base.contextWindow!, + maxTokens: model.maxTokens ?? preset.maxTokens ?? base.maxTokens!, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + ...(preset.cost ?? {}), + ...(model.cost ?? {}), + }, + compat: modelCompat(model, providerCompat, preset.compat), + }; + return { + model: providerModel, + defaultThinkingLevel: defaultThinkingLevel + ? clampThinkingLevelForModel( + providerModel, + parseThinkingLevelValue(defaultThinkingLevel, `LITELLM_MODELS_JSON model ${id} defaultThinkingLevel`)!, + ) + : undefined, + }; +} + +function parseModels(providerCompat: Record): NormalisedLiteLlmModel[] { + const json = process.env.LITELLM_MODELS_JSON?.trim(); + if (json) { + const parsed = JSON.parse(json) as unknown; + if (!Array.isArray(parsed)) throw new Error("LITELLM_MODELS_JSON must be a JSON array"); + return parsed.map((entry) => normaliseModel(entry as LiteLlmModel, providerCompat)); + } + + const csv = process.env.LITELLM_MODELS?.trim(); + if (csv) { + return csv + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + .map((id) => modelFromId(id)) + .map((model) => normaliseModel(model, providerCompat)); + } + + const fallback = process.env.LITELLM_DEFAULT_MODEL?.trim(); + return fallback ? [normaliseModel(modelFromId(fallback), providerCompat)] : []; +} + +function defaultThinkingLevel(): ThinkingLevel | undefined { + return parseThinkingLevelValue(process.env.LITELLM_DEFAULT_THINKING, "LITELLM_DEFAULT_THINKING", true); +} + +function resolvedEffort(model: ProviderModel, thinkingLevel: ThinkingLevel): string { + const mapped = model.thinkingLevelMap?.[thinkingLevel]; + if (mapped === null) return `${thinkingLevel}(unsupported)`; + return mapped ?? thinkingLevel; +} + +export function litellmRequestHint(model: ProviderModel, thinkingLevel: ThinkingLevel | undefined): string { + if (!model.reasoning) return "reasoning=disabled"; + + const compat = (model.compat ?? {}) as Record; + const format = compat.thinkingFormat; + const thinkingEnabled = Boolean(thinkingLevel && thinkingLevel !== "off"); + const effort = thinkingEnabled ? resolvedEffort(model, thinkingLevel!) : undefined; + + if (model.api === "openai-responses") { + return thinkingEnabled + ? `reasoning.effort=${effort}` + : `reasoning.effort=${String(model.thinkingLevelMap?.off ?? "none")}`; + } + if (model.api !== "openai-completions") return "api-specific"; + if (format === "deepseek") { + return thinkingEnabled ? `thinking.type=enabled,reasoning_effort=${effort}` : "thinking.type=disabled"; + } + if (format === "openrouter") { + return thinkingEnabled ? `reasoning.effort=${effort}` : "reasoning.effort=none"; + } + if (format === "together") { + return thinkingEnabled + ? compat.supportsReasoningEffort === false + ? "reasoning.enabled=true" + : `reasoning.enabled=true,reasoning_effort=${effort}` + : "reasoning.enabled=false"; + } + if (["zai", "qwen", "qwen-chat-template"].includes(String(format))) { + return thinkingEnabled ? "enable_thinking=true" : "enable_thinking=false"; + } + if (thinkingEnabled && compat.supportsReasoningEffort !== false) return `reasoning_effort=${effort}`; + if (thinkingEnabled) return "reasoning=not-sent(supportsReasoningEffort=false)"; + return "reasoning=off"; +} + +function logResolvedConfig(config: ResolvedLiteLlmConfig, phase: "startup" | "runtime"): void { + const model = config.defaultModel; + const compat = (model.compat ?? {}) as Record; + const thinking = config.thinkingLevel ?? "unset"; + console.log( + `${LOG_PREFIX} ${phase} config: ` + + `api=${model.api} ` + + `defaultModel=${config.defaultModelId} ` + + `reasoning=${model.reasoning} ` + + `defaultThinking=${thinking} ` + + `compat.thinkingFormat=${String(compat.thinkingFormat ?? "auto")} ` + + `compat.supportsReasoningEffort=${String(compat.supportsReasoningEffort ?? "auto")} ` + + `compat.maxTokensField=${String(compat.maxTokensField ?? "auto")} ` + + `request=${litellmRequestHint(model, config.thinkingLevel)}`, + ); + for (const entry of config.models) { + const levels = supportedThinkingLevelsForModel(entry); + const defaultThinking = + config.modelThinkingDefaults[modelKey(entry.id)] ?? + (config.globalThinkingLevel ? clampThinkingLevelForModel(entry, config.globalThinkingLevel) : undefined); + const hints = + levels + .filter((level) => level !== "off") + .map((level) => `${level}:${litellmRequestHint(entry, level)}`) + .join("|") || litellmRequestHint(entry, "off"); + console.log( + `${LOG_PREFIX} ${phase} model: ` + + `model=${entry.id} api=${entry.api} reasoning=${entry.reasoning} ` + + `defaultThinking=${defaultThinking ?? "unset"} ` + + `levels=${levels.join(",")} ` + + `requests=${hints}`, + ); + } +} + +export function resolveLiteLlmConfig(): ResolvedLiteLlmConfig | null { + if (cachedConfig !== undefined) return cachedConfig; + + const baseUrl = process.env.LITELLM_BASE_URL?.trim(); + if (!baseUrl) { + cachedConfig = null; + return cachedConfig; + } + + const providerApi = parseApi(process.env.LITELLM_API, "openai-completions"); + const providerCompat = parseCompat(); + const modelEntries = parseModels(providerCompat); + const models = modelEntries.map((entry) => entry.model); + if (models.length === 0) { + console.warn(`${LOG_PREFIX} LITELLM_BASE_URL is set but no models were provided`); + cachedConfig = null; + return cachedConfig; + } + + const defaultModelId = process.env.LITELLM_DEFAULT_MODEL?.trim() || models[0]!.id; + const defaultEntry = modelEntries.find((entry) => entry.model.id === defaultModelId); + const defaultModel = defaultEntry?.model; + if (!defaultModel) { + throw new Error(`LITELLM_DEFAULT_MODEL ${defaultModelId} is not present in LITELLM_MODELS/LITELLM_MODELS_JSON`); + } + + const globalThinkingLevel = defaultThinkingLevel(); + const modelThinkingDefaults = Object.fromEntries( + modelEntries + .filter((entry): entry is NormalisedLiteLlmModel & { defaultThinkingLevel: ThinkingLevel } => + Boolean(entry.defaultThinkingLevel), + ) + .map((entry) => [modelKey(entry.model.id), entry.defaultThinkingLevel]), + ); + + cachedConfig = { + baseUrl, + providerApi, + providerCompat, + models, + defaultModelId, + defaultModel, + globalThinkingLevel, + thinkingLevel: + defaultEntry.defaultThinkingLevel ?? + (globalThinkingLevel ? clampThinkingLevelForModel(defaultModel, globalThinkingLevel) : undefined), + modelThinkingDefaults, + }; + return cachedConfig; +} + +export function resetLiteLlmConfigForTests(): void { + cachedConfig = undefined; + startupConfigLogged = false; +} + +export function logLiteLlmStartupConfig(): void { + if (startupConfigLogged) return; + startupConfigLogged = true; + const config = resolveLiteLlmConfig(); + if (config) logResolvedConfig(config, "startup"); +} + +export function litellmRuntimeConfig(): Partial { + const config = resolveLiteLlmConfig(); + if (!config) return {}; + + const providerConfig: ProviderConfig = { + name: "LiteLLM", + baseUrl: config.baseUrl, + api: config.providerApi, + apiKey: "LITELLM_API_KEY", + models: config.models, + }; + + return { + configureModelRegistry(modelRegistry) { + modelRegistry.registerProvider("litellm", providerConfig); + console.log( + `${LOG_PREFIX} registered ${config.models.length} model(s); providerDefaultApi=${config.providerApi}`, + ); + logResolvedConfig(config, "runtime"); + }, + defaultModelProvider: "litellm", + defaultModelId: config.defaultModelId, + defaultThinkingLevel: config.globalThinkingLevel, + modelThinkingDefaults: config.modelThinkingDefaults, + }; +} diff --git a/src/routes.ts b/src/routes.ts deleted file mode 100644 index b1cc9b2..0000000 --- a/src/routes.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * HTTP routes — Hono OpenAPIHono app exposing AgentRuntime over REST + SSE. - * - * Surface (mounted on the server under no prefix; the server adds /v1): - * GET /sessions list sessions (disk + in-memory) - * POST /sessions create new session - * GET /sessions/{id} persisted message history - * GET /sessions/{id}/events SSE stream of pi AgentSessionEvents - * POST /sessions/{id}/prompt send a user prompt - * POST /sessions/{id}/abort abort in-flight run - * GET /healthz liveness + channel stats - * - * The SSE endpoint is *not* declared via @hono/zod-openapi — its response - * is a long-lived stream, not a JSON body, and the OpenAPI tooling for - * SSE is weak. We register a plain Hono GET for it and document it in the - * spec manually below so consumers see the path. - */ -import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; -import { streamSSE } from "hono/streaming"; -import type { AgentRuntime } from "./runtime.js"; -import { - CreateSessionResponseSchema, - ErrorResponseSchema, - HealthResponseSchema, - ListSessionsResponseSchema, - OkResponseSchema, - PromptRequestSchema, - SessionIdParamSchema, - SessionMessagesResponseSchema, -} from "./schemas.js"; -import { channelStats, subscribe } from "./sseBroker.js"; - -/** Heartbeat cadence for SSE keepalive. Keeps proxies / LBs from closing idle streams. */ -const SSE_HEARTBEAT_MS = 15_000; - -/** - * Build the Hono app exposing the runtime. Versioning is the caller's - * job (server.ts mounts this under /v1) so we can move /v2 alongside - * later without rewriting routes. - */ -export function createSessionsApp(runtime: AgentRuntime): OpenAPIHono { - const app = new OpenAPIHono(); - - // ── GET /sessions ──────────────────────────────────────────────── - app.openapi( - createRoute({ - method: "get", - path: "/sessions", - tags: ["sessions"], - summary: "List sessions (persisted + in-memory not yet flushed).", - responses: { - 200: { - description: "Sessions, newest first.", - content: { - "application/json": { schema: ListSessionsResponseSchema }, - }, - }, - }, - }), - async (c) => { - const sessions = await runtime.listSessions(); - return c.json({ sessions }, 200); - }, - ); - - // ── POST /sessions ─────────────────────────────────────────────── - app.openapi( - createRoute({ - method: "post", - path: "/sessions", - tags: ["sessions"], - summary: "Create a new session.", - responses: { - 200: { - description: "Newly created session metadata.", - content: { - "application/json": { schema: CreateSessionResponseSchema }, - }, - }, - }, - }), - async (c) => { - const created = await runtime.createNewSession(); - return c.json(created, 200); - }, - ); - - // ── GET /sessions/{id} ─────────────────────────────────────────── - app.openapi( - createRoute({ - method: "get", - path: "/sessions/{id}", - tags: ["sessions"], - summary: "Persisted message history for a session.", - request: { params: SessionIdParamSchema }, - responses: { - 200: { - description: "Messages for the session.", - content: { - "application/json": { schema: SessionMessagesResponseSchema }, - }, - }, - 404: { - description: "Unknown session id.", - content: { "application/json": { schema: ErrorResponseSchema } }, - }, - }, - }), - async (c) => { - const { id } = c.req.valid("param"); - const messages = await runtime.getSessionMessages(id); - if (messages === null) return c.json({ error: "session not found" }, 404); - return c.json({ id, messages }, 200); - }, - ); - - // ── POST /sessions/{id}/prompt ─────────────────────────────────── - app.openapi( - createRoute({ - method: "post", - path: "/sessions/{id}/prompt", - tags: ["sessions"], - summary: "Send a user prompt. Events flow over the SSE stream.", - request: { - params: SessionIdParamSchema, - body: { - required: true, - content: { "application/json": { schema: PromptRequestSchema } }, - }, - }, - responses: { - 200: { - description: "Prompt accepted and queued.", - content: { "application/json": { schema: OkResponseSchema } }, - }, - 404: { - description: "Unknown session id.", - content: { "application/json": { schema: ErrorResponseSchema } }, - }, - }, - }), - async (c) => { - const { id } = c.req.valid("param"); - const { text } = c.req.valid("json"); - // Fire-and-forget: events flow over SSE, errors surface there too. - runtime.sendPrompt(id, text).catch((err) => { - console.error("[agent-server] prompt failed:", err); - }); - return c.json({ ok: true } as const, 200); - }, - ); - - // ── POST /sessions/{id}/abort ──────────────────────────────────── - app.openapi( - createRoute({ - method: "post", - path: "/sessions/{id}/abort", - tags: ["sessions"], - summary: "Abort the in-flight run on a session. No-op if idle.", - request: { params: SessionIdParamSchema }, - responses: { - 200: { - description: "Abort accepted (or no-op if session was idle).", - content: { "application/json": { schema: OkResponseSchema } }, - }, - 404: { - description: "Unknown session id.", - content: { "application/json": { schema: ErrorResponseSchema } }, - }, - }, - }), - async (c) => { - const { id } = c.req.valid("param"); - try { - await runtime.abortSession(id); - return c.json({ ok: true } as const, 200); - } catch (err) { - return c.json({ error: String(err) }, 404); - } - }, - ); - - // ── GET /healthz ───────────────────────────────────────────────── - app.openapi( - createRoute({ - method: "get", - path: "/healthz", - tags: ["meta"], - summary: "Liveness + diagnostic counters.", - responses: { - 200: { - description: "OK.", - content: { "application/json": { schema: HealthResponseSchema } }, - }, - }, - }), - (c) => - c.json( - { - ok: true as const, - service: "agent-server" as const, - time: new Date().toISOString(), - channels: channelStats(), - }, - 200, - ), - ); - - // ── GET /sessions/{id}/events (SSE — not in OpenAPI body schemas) ── - // - // Documented in the OpenAPI registry as text/event-stream so consumers - // see the path, but no JSON schema is generated for it. The frontend - // consumes this via `EventSource`; eventx-backend pipes the upstream - // stream byte-for-byte. - app.openAPIRegistry.registerPath({ - // pure documentation for reference - method: "get", - path: "/sessions/{id}/events", - tags: ["sessions"], - summary: - "Server-Sent Events stream of pi AgentSessionEvents for the session.", - request: { params: SessionIdParamSchema }, - responses: { - 200: { - description: - "SSE stream. Each event is `data: ` carrying a pi AgentSessionEvent.", - content: { - "text/event-stream": { schema: { type: "string" } as never }, - }, - }, - 404: { - description: "Unknown session id.", - content: { "application/json": { schema: ErrorResponseSchema } }, - }, - }, - }); - - // actual handler for the SSE endpoint - app.get("/sessions/:id/events", async (c) => { - const id = c.req.param("id"); - const session = await runtime.ensureSession(id); - if (!session) return c.json({ error: "session not found" }, 404); - - return streamSSE(c, async (stream) => { - // Per-subscriber queue + wakeup. Listener pushes; loop drains. - const queue: string[] = []; - let wake: (() => void) | null = null; - const wait = () => - new Promise((resolve) => { - wake = resolve; - }); - - const unsubscribe = subscribe(id, (event) => { - queue.push(JSON.stringify(event)); - if (wake) { - wake(); - wake = null; - } - }); - - stream.onAbort(() => { - unsubscribe(); - if (wake) { - wake(); - wake = null; - } - }); - - await stream.writeSSE({ data: `connected to ${id}` }); - - let lastBeat = Date.now(); - while (!stream.aborted) { - if (queue.length === 0) { - const timer = new Promise((resolve) => - setTimeout(resolve, SSE_HEARTBEAT_MS), - ); - await Promise.race([wait(), timer]); - } - if (stream.aborted) break; - - while (queue.length > 0) { - await stream.writeSSE({ data: queue.shift()! }); - } - - if (Date.now() - lastBeat >= SSE_HEARTBEAT_MS) { - // Named event — frontend EventSource ignores it (no listener), - // but the bytes keep proxies happy. - await stream.writeSSE({ event: "heartbeat", data: "ping" }); - lastBeat = Date.now(); - } - } - - unsubscribe(); - }); - }); - - return app; -} diff --git a/src/runtime.ts b/src/runtime.ts deleted file mode 100644 index be0bfe9..0000000 --- a/src/runtime.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * AgentRuntime — pi SDK orchestrator scoped to one Appx app. - * - * Each app instantiates one runtime pointed at: - * - projectDir: the cwd handed to pi (skill discovery roots here, so - * `.pi/skills/` and `.agents/skills/` under projectDir are picked up) - * - sessionsDir: where pi writes session JSONL files (typically - * /sessions). Sessions are first-class files: list reads from - * disk, getById lazily reopens any persisted session, createNew creates - * a new file. - * - * Owns: - * - one AuthStorage + ModelRegistry per runtime - * - Map of in-memory live sessions - * - subscription bridge: every AgentSessionEvent → publish(sessionId, event) - * - * No module-level singletons — multiple apps in the same process (e.g. tests) - * each get their own runtime with isolated state. - */ -import { mkdirSync, readFileSync } from "node:fs"; -import { isAbsolute, resolve } from "node:path"; -import { - type AgentSession, - type AgentSessionEvent, - AuthStorage, - createAgentSession, - DefaultResourceLoader, - getAgentDir, - ModelRegistry, - SessionManager, - type SessionInfo, - SettingsManager, -} from "@earendil-works/pi-coding-agent"; -import { publish } from "./sseBroker.js"; - -/** Configuration for a single AgentRuntime instance. */ -export type AgentRuntimeConfig = { - /** Absolute path handed to pi as the session cwd. Skill discovery is rooted here. */ - projectDir: string; - /** Absolute path where pi writes session JSONL files. Created if missing. */ - sessionsDir: string; - /** - * Optional Anthropic API key to inject into AuthStorage at runtime. If - * unset, the runtime falls back to whatever's in `~/.pi/agent/auth.json` - * (typical for local dev). - */ - anthropicApiKey?: string; - /** - * Optional explicit path to the agent's system-prompt markdown file - * (typically `AGENTS.md` per the App Anatomy spec). When set, pi's - * built-in AGENTS.md / CLAUDE.md auto-discovery is disabled and only - * this file's contents are used as the system prompt. Relative paths - * are resolved against `projectDir`. - * - * Why this matters: by default pi walks every ancestor of `cwd` - * looking for AGENTS.md / CLAUDE.md and concatenates them, which - * means an app's running agent inherits whatever developer notes - * happen to be lying around the repo. Pin the path explicitly so the - * agent's prompt is exactly what the app intends. - */ - agentsFile?: string; - /** Optional logger; defaults to console. */ - logger?: Pick; -}; - -/** - * Listing view returned by GET /api/sessions. Stable across apps — the - * eventx-frontend chat reducer (and any future app's UI) consume this shape. - */ -export type SessionRow = { - id: string; - createdAt: string; - firstMessage: string; - messageCount: number; -}; - -type LiveSession = { - session: AgentSession; - unsubscribe: () => void; - /** When this session was first bound (created or reopened). Fallback createdAt for sessions not yet flushed to disk. */ - boundAt: string; -}; - -export class AgentRuntime { - private readonly projectDir: string; - private readonly sessionsDir: string; - private readonly authStorage: AuthStorage; - private readonly modelRegistry: ModelRegistry; - private readonly logger: Pick; - private readonly live = new Map(); // todo: rename to liveSessions - /** Resolved absolute path to the agent's system-prompt file, if pinned. */ - private readonly agentsFile: string | undefined; - /** Cached system-prompt content, read once at construction. */ - private readonly systemPrompt: string | undefined; - - constructor(config: AgentRuntimeConfig) { - this.projectDir = config.projectDir; - this.sessionsDir = config.sessionsDir; - this.logger = config.logger ?? console; - mkdirSync(this.sessionsDir, { recursive: true }); - - this.authStorage = AuthStorage.create(); - this.modelRegistry = ModelRegistry.create(this.authStorage); - - if (config.agentsFile) { - const path = isAbsolute(config.agentsFile) - ? config.agentsFile - : resolve(this.projectDir, config.agentsFile); - try { - this.systemPrompt = readFileSync(path, "utf8"); - this.agentsFile = path; - this.logger.log( - `[agent] system prompt loaded from ${path} (${this.systemPrompt.length} chars)`, - ); - } catch (err) { - this.logger.error( - `[agent] failed to read agentsFile ${path}: ${String(err)}`, - ); - throw err; - } - } - - if (config.anthropicApiKey) { - this.authStorage.setRuntimeApiKey("anthropic", config.anthropicApiKey); - this.logger.log("[agent] runtime ANTHROPIC_API_KEY injected"); - } else { - this.logger.log( - "[agent] no ANTHROPIC_API_KEY provided; relying on AuthStorage defaults (~/.pi/agent/auth.json)", - ); - } - } - - /** - * Build a fresh DefaultResourceLoader configured with our pinned - * system-prompt file, if any. Pi's SDK constructs a default loader - * (with full ancestor AGENTS.md/CLAUDE.md discovery) when none is - * passed, so we always pass our own to keep behaviour deterministic. - * A new loader per session is fine — pi creates one anyway. - */ - private async makeResourceLoader(): Promise { - const settingsManager = SettingsManager.create( - this.projectDir, - getAgentDir(), - ); - const loader = new DefaultResourceLoader({ - cwd: this.projectDir, - agentDir: getAgentDir(), - settingsManager, - // When we have an explicit agentsFile, suppress all ancestor-walk - // AGENTS.md/CLAUDE.md discovery and feed our content via - // systemPrompt instead. - noContextFiles: this.systemPrompt !== undefined, - systemPrompt: this.systemPrompt, - }); - await loader.reload(); - return loader; - } - - /** - * Wire an AgentSession's event stream into the SSE broker. Called once - * per session right after it's created or reopened. The unsubscribe - * handle is kept so we can detach if we ever evict. - */ - private bind(session: AgentSession): void { - const id = session.sessionId; - const unsubscribe = session.subscribe((event: AgentSessionEvent) => { - publish(id, event); - }); - this.live.set(id, { - session, - unsubscribe, - boundAt: new Date().toISOString(), - }); - } - - /** - * Create a brand-new session. Pi writes a new JSONL file under - * sessionsDir on first message_end. Returns minimal metadata. - */ - async createNewSession(): Promise<{ id: string; createdAt: string }> { - const { session } = await createAgentSession({ - authStorage: this.authStorage, - modelRegistry: this.modelRegistry, - sessionManager: SessionManager.create(this.projectDir, this.sessionsDir), - resourceLoader: await this.makeResourceLoader(), - }); - this.bind(session); - return { - id: session.sessionId, - createdAt: new Date().toISOString(), - }; - } - - /** - * Get a live AgentSession by id, lazily reopening from disk if not in - * memory. Returns null if no session file exists with that id. - */ - async ensureSession(id: string): Promise { - const existing = this.live.get(id); - if (existing) return existing.session; - - const sessions = await SessionManager.list( - this.projectDir, - this.sessionsDir, - ); - const info = sessions.find((s) => s.id === id); - if (!info) return null; - - const { session } = await createAgentSession({ - authStorage: this.authStorage, - modelRegistry: this.modelRegistry, - sessionManager: SessionManager.open(info.path), - resourceLoader: await this.makeResourceLoader(), - }); - this.bind(session); - return session; - } - - /** - * List all sessions, merging two sources of truth: - * 1. Persisted sessions on disk (SessionManager.list) - * 2. Live in-memory sessions not yet flushed to disk (newly created, - * no prompts yet — pi writes the file lazily on first message) - * - * Disk metadata wins when both exist. Sorted newest-first. - */ - async listSessions(): Promise { - const list: SessionInfo[] = await SessionManager.list( - this.projectDir, - this.sessionsDir, - ); - const onDisk = new Set(list.map((s) => s.id)); - - const rows: SessionRow[] = list.map((info) => ({ - id: info.id, - createdAt: info.created.toISOString(), - firstMessage: info.firstMessage ?? "", - messageCount: info.messageCount, - })); - - for (const [id, entry] of this.live) { - if (onDisk.has(id)) continue; - const messages = entry.session.state.messages as Array<{ - role: string; - content: Array<{ type: string; text?: string }>; - }>; - const firstUser = messages.find((m) => m.role === "user"); - const firstText = - firstUser?.content.find((c) => c.type === "text")?.text ?? ""; - rows.push({ - id, - createdAt: entry.boundAt, - firstMessage: firstText, - messageCount: messages.length, - }); - } - - return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - } - - /** - * Return persisted message history for a session, lazy-loading the - * AgentSession if it isn't live yet. Used by the frontend on session - * open to populate the chat before the SSE stream starts. - */ - async getSessionMessages(id: string): Promise { - const session = await this.ensureSession(id); - if (!session) return null; - return session.state.messages; - } - - /** - * Send a user prompt to a session. Events flow over SSE to any - * subscribers. Returns once the prompt has been queued; the agent runs - * asynchronously. - */ - async sendPrompt(id: string, text: string): Promise { - const session = await this.ensureSession(id); - if (!session) throw new Error(`session ${id} not found`); - if (session.isStreaming) { - // While the agent is streaming, prompt() requires a streamingBehavior. - // "steer" queues the message for delivery as soon as the current - // assistant turn's tool calls finish — i.e. it actually interrupts - // the agent's plan rather than waiting for it to fully stop - // ("followUp"). Equivalent to session.steer(text). - await session.prompt(text, { streamingBehavior: "steer" }); - return; - } - await session.prompt(text); - } - - /** - * Abort the current operation on a session (the agent's in-flight LLM - * call and any running tool). Resolves once pi has torn the run down; - * the session itself stays usable — subsequent prompts work normally. - * No-op if the session isn't streaming. Throws if the session id is - * unknown. - */ - async abortSession(id: string): Promise { - const session = await this.ensureSession(id); - if (!session) throw new Error(`session ${id} not found`); - if (!session.isStreaming) return; - await session.abort(); - } -} diff --git a/src/runtime/projectRegistry.ts b/src/runtime/projectRegistry.ts new file mode 100644 index 0000000..ebdc3eb --- /dev/null +++ b/src/runtime/projectRegistry.ts @@ -0,0 +1,281 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { AuthStorage, ModelRegistry, type ModelRegistry as ModelRegistryType } from "@earendil-works/pi-coding-agent"; +import { AgentCredentialsService } from "../credentials/credentialsService.js"; +import { isValidProjectSlug, slugify, withCollisionSuffix } from "../utils/slug.js"; +import { ProjectRuntime, type ProjectRuntimeConfig } from "./projectRuntime.js"; +import { type ProjectRecord, ProjectStore } from "./projectStore.js"; + +/** Directory under WORKSPACE_DIR holding org-global + agent-server state. */ +export const GLOBAL_DIR_NAME = ".pi-global"; +/** Subdirectory of the global dir holding per-project session transcripts. */ +const SESSIONS_DIR_NAME = "sessions"; +/** Filename of the durable project metadata registry. */ +const PROJECTS_FILE_NAME = "projects.json"; + +/** + * Public, serialisable view of a project — the shape returned by the + * `/v1/projects` endpoints. Combines persisted metadata with the derived + * (non-persisted) absolute working directory. + */ +export type ProjectInfo = ProjectRecord & { + /** Absolute working directory: `WORKSPACE_DIR/{id}`. Derived, never stored. */ + projectDir: string; +}; + +/** + * ProjectRegistry config. The registry derives the global agent dir and the + * per-project layout from `workspaceDir`; callers pass only `workspaceDir` plus + * the shared Pi resource/runtime options (extensions, skills, model defaults). + * + * Shared services (authStorage / modelRegistry / credentials) are owned and + * injected by the registry, so they are omitted here. `sessionsDir` and + * `projectDir` are owned by the workspace convention and likewise omitted. + */ +export type ProjectRegistryConfig = Omit< + ProjectRuntimeConfig, + "authStorage" | "modelRegistry" | "credentials" | "projectDir" | "sessionsDir" +> & { + /** Absolute root holding every project dir plus `.pi-global/`. Must exist. */ + workspaceDir: string; +}; + +type RuntimeEntry = { + projectDir: string; + runtime: ProjectRuntime; +}; + +/** + * Registry of per-project ProjectRuntimes sharing one process-global + * AuthStorage / ModelRegistry / AgentCredentialsService. + * + * Ownership model (see + * docs/architecture/project-lifecycle-and-workspace-layout.md): + * - The registry **owns** project identity and on-disk layout. Projects are + * created explicitly via `createProject({ name })`, which assigns an + * immutable slug `id`, creates `WORKSPACE_DIR/{id}/`, and persists metadata + * to `WORKSPACE_DIR/.pi-global/projects.json` (the source of truth). + * - `projects.json` is rehydrated on boot, so projects (and their `.pi/` + * config + centralised session transcripts) survive restarts. + * - Runtimes are built lazily on first use (`getRuntime`) and cached; the + * persisted metadata, not the in-memory map, defines which projects exist. + * + * Filesystem convention: + * WORKSPACE_DIR/ + * ├── .pi-global/ auth.json, models.json, projects.json, sessions/{id}/ + * └── {id}/.pi/ AGENTS.md, skills, extensions, settings (committable) + * + * Construction is async because shared services are built up front. Use the + * static factory: + * + * const registry = await ProjectRegistry.create({ workspaceDir }); + * const project = registry.createProject({ name: "My App" }); + * const runtime = await registry.getRuntime(project.id); + */ +export class ProjectRegistry { + private readonly config: ProjectRegistryConfig; + private readonly workspaceDir: string; + private readonly agentDir: string; + private readonly store: ProjectStore; + private readonly authStorage: AuthStorage; + private readonly modelRegistry: ModelRegistryType; + private readonly runtimes = new Map(); + readonly credentials: AgentCredentialsService; + + /** + * Async factory. Resolves the workspace layout, loads the durable project + * registry, and sets up shared auth/model/credentials state. Project runtimes + * are built lazily via `getRuntime()`. + */ + static async create(config: ProjectRegistryConfig): Promise { + const workspaceDir = resolve(config.workspaceDir); + const agentDir = join(workspaceDir, GLOBAL_DIR_NAME); + mkdirSync(agentDir, { recursive: true }); + + const resolvedConfig: ProjectRegistryConfig = { ...config, workspaceDir }; + + // One AuthStorage / ModelRegistry / projects.json shared by every runtime + // so credentials, the model catalog, and the project registry all target + // the same files under .pi-global. + const authStorage = AuthStorage.create(join(agentDir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); + resolvedConfig.configureModelRegistry?.(modelRegistry); + + const store = ProjectStore.load(join(agentDir, PROJECTS_FILE_NAME)); + + const credentials = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: join(agentDir, "models.json"), + defaultModelProvider: resolvedConfig.defaultModelProvider, + defaultModelId: resolvedConfig.defaultModelId, + defaultThinkingLevel: resolvedConfig.defaultThinkingLevel, + modelThinkingDefaults: resolvedConfig.modelThinkingDefaults, + logger: resolvedConfig.logger, + }); + + return new ProjectRegistry( + resolvedConfig, + workspaceDir, + agentDir, + store, + authStorage, + modelRegistry, + credentials, + ); + } + + private constructor( + config: ProjectRegistryConfig, + workspaceDir: string, + agentDir: string, + store: ProjectStore, + authStorage: AuthStorage, + modelRegistry: ModelRegistryType, + credentials: AgentCredentialsService, + ) { + this.config = config; + this.workspaceDir = workspaceDir; + this.agentDir = agentDir; + this.store = store; + this.authStorage = authStorage; + this.modelRegistry = modelRegistry; + this.credentials = credentials; + } + + /** Absolute working directory for a project id. Derived, never persisted. */ + projectDir(id: string): string { + return join(this.workspaceDir, id); + } + + /** Per-project session transcript directory under `.pi-global/sessions/{id}`. */ + private sessionsDir(id: string): string { + return join(this.agentDir, SESSIONS_DIR_NAME, id); + } + + /** Attach the derived working directory to a persisted record. */ + private toInfo(record: ProjectRecord): ProjectInfo { + return { ...record, projectDir: this.projectDir(record.id) }; + } + + /** + * Create a project, or return the existing one (idempotent). + * + * Idempotency key is the exact `name`: re-creating the same name (e.g. an + * upstream caller re-POSTing after a restart) returns the existing project + * untouched. A *different* name that slugifies to an already-taken id is a + * genuine collision and gets a short random suffix so both coexist. + * + * Side effects on a fresh create: makes `WORKSPACE_DIR/{id}/` and persists the + * record to `projects.json`. The runtime is built lazily on first `getRuntime`. + */ + createProject({ name }: { name: string }): ProjectInfo { + const trimmedName = name.trim(); + if (!trimmedName) throw new InvalidProjectNameError("project name is required"); + + const baseSlug = slugify(trimmedName); + if (!isValidProjectSlug(baseSlug)) { + throw new InvalidProjectNameError(`project name does not yield a valid id: ${JSON.stringify(name)}`); + } + + const existing = this.store.get(baseSlug); + if (existing) { + // Same name → idempotent return. Different name → collision, suffix it. + if (existing.name === trimmedName) return this.toInfo(existing); + return this.insertProject(this.freeCollisionSlug(baseSlug), trimmedName); + } + return this.insertProject(baseSlug, trimmedName); + } + + /** Generate a suffixed slug not already taken by another project. */ + private freeCollisionSlug(baseSlug: string): string { + let candidate = withCollisionSuffix(baseSlug); + while (this.store.has(candidate) || !isValidProjectSlug(candidate)) { + candidate = withCollisionSuffix(baseSlug); + } + return candidate; + } + + /** Materialise a new project on disk + in the durable registry. */ + private insertProject(id: string, name: string): ProjectInfo { + mkdirSync(this.projectDir(id), { recursive: true }); + const record = this.store.add({ + id, + name, + createdAt: new Date().toISOString(), + }); + this.config.logger?.log(`[agent-server] created project id=${id} dir=${this.projectDir(id)}`); + return this.toInfo(record); + } + + /** Metadata for one registered project, or null if unknown. */ + getProject(id: string): ProjectInfo | null { + const record = this.store.get(id); + return record ? this.toInfo(record) : null; + } + + /** All registered projects, newest first. */ + listProjects(): ProjectInfo[] { + return this.store.list().map((record) => this.toInfo(record)); + } + + /** + * Resolve (and lazily build) the ProjectRuntime for a *registered* project. + * Returns null when the id was never created — session routes turn this into + * a 404. There is no implicit creation: projects must be made via + * `createProject` first. + */ + async getRuntime(id: string): Promise { + const record = this.store.get(id); + if (!record) return null; + + const projectDir = this.projectDir(id); + const existing = this.runtimes.get(id); + if (existing?.projectDir === projectDir) return existing.runtime; + + const runtime = await ProjectRuntime.create({ + ...this.config, + projectDir, + // Centralise transcripts under .pi-global/sessions/{id} so the project's + // own .pi/ stays config-only (committable) and transcripts survive on the + // workspace volume independently of the project tree. + sessionsDir: this.sessionsDir(id), + agentDir: this.agentDir, + credentials: this.credentials, + authStorage: this.authStorage, + modelRegistry: this.modelRegistry, + // Shared modelRegistry was already configured in create(); clear the hook + // so per-project ProjectRuntime.create doesn't double-apply it. + configureModelRegistry: undefined, + }); + this.runtimes.set(id, { projectDir, runtime }); + return runtime; + } + + /** + * Remove a project: evict the cached runtime, drop the metadata record, and + * delete both on-disk locations — the working dir `WORKSPACE_DIR/{id}/` and + * the centralised transcripts `.pi-global/sessions/{id}/`. Returns false if + * the project was unknown. + */ + removeProject(id: string): boolean { + if (!this.store.has(id)) return false; + this.runtimes.delete(id); + this.store.remove(id); + rmSync(this.projectDir(id), { recursive: true, force: true }); + rmSync(this.sessionsDir(id), { recursive: true, force: true }); + this.config.logger?.log(`[agent-server] removed project id=${id}`); + return true; + } +} + +/** + * Thrown when a supplied project name cannot produce a valid id. Surfaced as a + * 400 by the HTTP layer (distinct from a generic 500). + */ +export class InvalidProjectNameError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidProjectNameError"; + } +} diff --git a/src/runtime/projectRuntime.ts b/src/runtime/projectRuntime.ts new file mode 100644 index 0000000..58b59e0 --- /dev/null +++ b/src/runtime/projectRuntime.ts @@ -0,0 +1,608 @@ +/** + * ProjectRuntime — pi SDK orchestrator scoped to one Appx project. + * + * Each app instantiates one runtime pointed at: + * - projectDir: the cwd handed to pi (skill discovery roots here, so + * `.pi/skills/` and `.agents/skills/` under projectDir are picked up) + * - sessionsDir: where pi writes session JSONL files. Defaults to + * `/.pi/sessions/` per Pi's project convention; callers + * may override for tests or non-conventional layouts. Sessions are + * first-class files: list reads from disk, getById lazily reopens + * any persisted session, createNew creates a new file. + * + * Owns: + * - one AgentSessionServices bundle (cwd-bound: ResourceLoader, + * SettingsManager, AuthStorage, ModelRegistry, diagnostics) shared + * across every session in this project — the bundle's + * ResourceLoader.reload() runs exactly once at project startup + * instead of once per session. + * - Map of in-memory live sessions. + * + * Per-session operations (prompt, abort, model changes, extension-UI + * routing) live on ProjectSession. Routes use the two-step lookup: + * + * const session = await runtime.getSession(id); + * if (!session) return 404; + * await session.sendPrompt(text); + * + * Construction is async via `ProjectRuntime.create(config)` because + * `createAgentSessionServices()` walks the filesystem to load + * extensions/skills/themes once per project. See + * docs/architecture/use-agent-session-services.md for the rationale. + * + * No module-level singletons — multiple apps in the same process (e.g. tests) + * each get their own runtime with isolated state. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; +import { + type AgentSession, + type AgentSessionRuntimeDiagnostic, + type AgentSessionServices, + AuthStorage, + type CreateAgentSessionOptions, + createAgentSessionFromServices, + createAgentSessionServices, + type ExtensionFactory, + getAgentDir, + type ModelRegistry as ModelRegistryType, + type SessionInfo, + SessionManager, +} from "@earendil-works/pi-coding-agent"; +import type { AgentCredentialsService } from "../credentials/credentialsService.js"; +import type { ThinkingLevel } from "../shared/thinking.js"; +import { ProjectSession } from "./projectSession.js"; + +type SessionModel = NonNullable; + +export type { + AgentAuthPrompt, + AgentAuthProviderRow, + AgentCustomProviderApi, + AgentCustomProviderModel, + AgentCustomProviderRow, + AgentModelRow, + AgentOAuthFlowState, + UpsertCustomProviderRequest, +} from "../credentials/credentialsService.js"; +export type { + ExtensionUiRequest, + ExtensionUiResponse, +} from "../shared/extensionUi.js"; +export type { ThinkingLevel } from "../shared/thinking.js"; +export type { SessionModelSettings } from "./projectSession.js"; + +/** Configuration for a single ProjectRuntime instance. */ +export type ProjectRuntimeConfig = { + /** Absolute path handed to pi as the session cwd. Skill discovery is rooted here. */ + projectDir: string; + /** + * Absolute path where pi writes session JSONL files. Optional — + * defaults to `/.pi/sessions/` per Pi's project + * convention. Created if missing. + */ + sessionsDir?: string; + /** Optional pi agent config dir. Defaults to Pi's standard ~/.pi/agent. */ + agentDir?: string; + /** Process-global credentials service shared with sibling runtimes. */ + credentials: AgentCredentialsService; + /** Optional shared Pi auth storage. Used by multi-project hosts. */ + authStorage?: AuthStorage; + /** Optional shared model registry. Used by multi-project hosts. */ + modelRegistry?: ModelRegistryType; + /** + * Optional Anthropic API key to inject into AuthStorage at runtime. If + * unset, the runtime falls back to whatever's in `~/.pi/agent/auth.json` + * (typical for local dev). + */ + anthropicApiKey?: string; + /** Hook for app-specific dynamic model/provider registration before session model selection. */ + configureModelRegistry?: (modelRegistry: ModelRegistryType) => void; + /** Optional explicit default model provider/id to pass into createAgentSession before Pi selects defaults. */ + defaultModelProvider?: string; + defaultModelId?: string; + /** Optional global fallback thinking level paired with defaultModelProvider/defaultModelId. */ + defaultThinkingLevel?: ThinkingLevel; + /** Optional per-model thinking defaults keyed as `${provider}/${modelId}`. */ + modelThinkingDefaults?: Record; + /** + * Extra Pi extension/package sources to load as temporary extensions. + * Supports local paths plus Pi package sources such as npm: and git:. + */ + extensionPaths?: string[]; + /** Extra Pi skill file/directory paths to load for this runtime. */ + skillPaths?: string[]; + /** Extra Pi prompt template file/directory paths to load for this runtime. */ + promptTemplatePaths?: string[]; + /** Extra Pi theme file/directory paths to load for this runtime. */ + themePaths?: string[]; + /** Inline extension factories, mostly useful for tests and embedded hosts. */ + extensionFactories?: ExtensionFactory[]; + /** Disable project/global extension discovery while still allowing extensionPaths/factories. */ + noExtensions?: boolean; + /** Disable project/global skill discovery while still allowing extension-provided resources. */ + noSkills?: boolean; + /** Disable project/global prompt template discovery. */ + noPromptTemplates?: boolean; + /** Disable project/global theme discovery. */ + noThemes?: boolean; + /** + * Optional **explicit override** for the agent's system-prompt + * markdown file. When set, pi's built-in AGENTS.md / CLAUDE.md + * ancestor walk is disabled and only this file's contents are used + * as the system prompt. Relative paths are resolved against + * `projectDir`. **A missing file at an explicitly configured path is + * a fatal startup error** — misconfiguration is loud. + * + * When unset, the runtime falls back to the project convention: + * `/.pi/AGENTS.md` is loaded if present and silently + * skipped if absent. Both default and per-project runtimes share + * this rule, which is why we no longer need a separate + * "defaultAgentsFile: false" kill switch at the registry level. + * + * Why pinning matters: by default pi walks every ancestor of `cwd` + * looking for AGENTS.md / CLAUDE.md and concatenates them, which + * means an app's running agent inherits whatever developer notes + * happen to be lying around the repo. Either form (explicit or + * convention default) suppresses that walk. + */ + agentsFile?: string; + /** Optional logger; defaults to console. */ + logger?: Pick; +}; + +/** + * Listing view returned by GET /api/sessions. Stable across apps — the + * eventx-frontend chat reducer (and any future app's UI) consume this shape. + */ +export type SessionRow = { + id: string; + createdAt: string; + firstMessage: string; + messageCount: number; +}; + +type ProjectRuntimeFields = { + projectDir: string; + sessionsDir: string; + credentials: AgentCredentialsService; + defaultModelProvider: string | undefined; + defaultModelId: string | undefined; + defaultThinkingLevel: ThinkingLevel | undefined; + logger: Pick; +}; + +export class ProjectRuntime { + /** Process-global credentials service shared across all sibling runtimes. */ + readonly credentials: AgentCredentialsService; + /** + * Pi's cwd-bound services bundle. Source of truth for AuthStorage, + * ModelRegistry, SettingsManager, ResourceLoader, agentDir, cwd, and + * non-fatal startup diagnostics. Shared across every session created + * by this runtime. + */ + readonly services: AgentSessionServices; + + private readonly projectDir: string; + private readonly sessionsDir: string; + private readonly defaultModelProvider: string | undefined; + private readonly defaultModelId: string | undefined; + private readonly defaultThinkingLevel: ThinkingLevel | undefined; + private readonly logger: Pick; + private readonly sessions = new Map(); + + /** + * Async factory. Builds the AgentSessionServices bundle (which runs + * `resourceLoader.reload()` once and registers extension-provided + * custom model providers into the shared modelRegistry) and + * constructs the runtime around it. + * + * Industry best practice: async work in a static factory rather than + * a constructor, since constructors can't be awaited and partially + * constructed objects are a footgun. + */ + static async create(config: ProjectRuntimeConfig): Promise { + const projectDir = resolve(config.projectDir); + const sessionsDir = resolveSessionsDir(config, projectDir); + const agentDir = config.agentDir ? resolve(config.agentDir) : getAgentDir(); + const logger = config.logger ?? console; + + mkdirSync(sessionsDir, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + ensureProjectGitignore(projectDir, logger); + + // Read pinned system prompt up-front so we can both feed it into + // the resource loader and suppress Pi's ancestor AGENTS.md walk. + const { systemPrompt, agentsFilePath } = resolveSystemPrompt(config, projectDir, logger); + + // Caller may share an AuthStorage across projects; otherwise build a + // project-local one against the resolved agentDir so our auth.json + // path matches every other runtime touching this agentDir. + const authStorage = config.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); + + if (config.anthropicApiKey) { + authStorage.setRuntimeApiKey("anthropic", config.anthropicApiKey); + logger.log("[agent] runtime ANTHROPIC_API_KEY injected"); + } else if (!config.authStorage) { + // Only log the fallback when we actually own the AuthStorage + // — when callers share one, they're responsible for its source. + logger.log( + `[agent] no ANTHROPIC_API_KEY provided; relying on AuthStorage defaults (${join(agentDir, "auth.json")})`, + ); + } + + // Build the services bundle. Pi creates ResourceLoader + + // SettingsManager here, runs reload() exactly once, and registers + // extension-provided custom providers into the (shared) + // modelRegistry. + const services = await createAgentSessionServices({ + cwd: projectDir, + agentDir, + authStorage, + modelRegistry: config.modelRegistry, + resourceLoaderOptions: { + additionalExtensionPaths: config.extensionPaths, + additionalSkillPaths: config.skillPaths, + additionalPromptTemplatePaths: config.promptTemplatePaths, + additionalThemePaths: config.themePaths, + extensionFactories: config.extensionFactories, + noExtensions: config.noExtensions, + noSkills: config.noSkills, + noPromptTemplates: config.noPromptTemplates, + noThemes: config.noThemes, + // When systemPrompt is pinned, suppress Pi's ancestor + // AGENTS.md/CLAUDE.md walk so the agent's prompt is exactly + // what the app intends and nothing else. + noContextFiles: systemPrompt !== undefined, + systemPrompt, + }, + }); + + if (agentsFilePath && systemPrompt !== undefined) { + logger.log(`[agent] system prompt loaded from ${agentsFilePath} (${systemPrompt.length} chars)`); + } + + // Apply caller's modelRegistry hook only if registry isn't shared. + // Shared registries are configured once at the registry level so + // we don't re-run the hook per project. + if (!config.modelRegistry) { + config.configureModelRegistry?.(services.modelRegistry); + } + + // Surface non-fatal diagnostics from services creation. Errors are + // logged but not thrown — matches the existing default-model auth + // check below, which logs without aborting startup. + for (const diagnostic of services.diagnostics) { + const log = diagnostic.type === "error" ? logger.error : logger.log; + log.call(logger, `[agent] ${diagnostic.type}: ${diagnostic.message}`); + } + + // Validate the configured default model resolves & has auth. + if (config.defaultModelProvider && config.defaultModelId) { + const model = services.modelRegistry.find(config.defaultModelProvider, config.defaultModelId); + if (!model) { + logger.error(`[agent] default model not found: ${config.defaultModelProvider}/${config.defaultModelId}`); + } else if (!services.modelRegistry.hasConfiguredAuth(model)) { + logger.error(`[agent] auth is not configured for default model ${model.provider}/${model.id}`); + } else { + logger.log(`[agent] default model: ${model.provider}/${model.id}`); + } + } + + return new ProjectRuntime( + { + projectDir, + sessionsDir, + credentials: config.credentials, + defaultModelProvider: config.defaultModelProvider, + defaultModelId: config.defaultModelId, + defaultThinkingLevel: config.defaultThinkingLevel, + logger, + }, + services, + ); + } + + private constructor(fields: ProjectRuntimeFields, services: AgentSessionServices) { + this.projectDir = fields.projectDir; + this.sessionsDir = fields.sessionsDir; + this.credentials = fields.credentials; + this.defaultModelProvider = fields.defaultModelProvider; + this.defaultModelId = fields.defaultModelId; + this.defaultThinkingLevel = fields.defaultThinkingLevel; + this.logger = fields.logger; + this.services = services; + } + + private sessionModelDefaults(): Pick { + const defaults: Pick = {}; + if (this.defaultModelProvider && this.defaultModelId) { + const model = this.services.modelRegistry.find(this.defaultModelProvider, this.defaultModelId) as + | SessionModel + | undefined; + if (model) { + defaults.model = model; + const thinkingLevel = this.credentials.defaultThinkingForModel(model as SessionModel); + if (thinkingLevel) defaults.thinkingLevel = thinkingLevel; + } + } + if (!defaults.thinkingLevel && this.defaultThinkingLevel) { + defaults.thinkingLevel = this.defaultThinkingLevel; + } + return defaults; + } + + /** Wrap a freshly created/reopened AgentSession in a ProjectSession and remember it. */ + private adopt(session: AgentSession): ProjectSession { + const ps = new ProjectSession(session, { + credentials: this.credentials, + modelRegistry: this.services.modelRegistry, + logger: this.logger, + }); + this.sessions.set(ps.sessionId, ps); + return ps; + } + + // ── Session collection ─────────────────────────────────────────── + + /** + * Create a brand-new session. Pi writes a new JSONL file under + * sessionsDir on first message_end. Returns the bound ProjectSession + * so callers can immediately act on it (subscribe to events, send a + * first prompt, list pending extension UI requests). + */ + async createNewSession(): Promise { + const { session } = await createAgentSessionFromServices({ + services: this.services, + sessionManager: SessionManager.create(this.projectDir, this.sessionsDir), + ...this.sessionModelDefaults(), + }); + return this.adopt(session); + } + + /** + * Get a live ProjectSession by id, lazily reopening from disk if not in + * memory. Returns null if no session file exists with that id. + */ + async getSession(id: string): Promise { + const existing = this.sessions.get(id); + if (existing) return existing; + + const sessions = await SessionManager.list(this.projectDir, this.sessionsDir); + const info = sessions.find((s) => s.id === id); + if (!info) return null; + + const { session } = await createAgentSessionFromServices({ + services: this.services, + sessionManager: SessionManager.open(info.path), + ...this.sessionModelDefaults(), + }); + return this.adopt(session); + } + + /** + * List all sessions, merging two sources of truth: + * 1. Persisted sessions on disk (SessionManager.list) + * 2. Live in-memory sessions not yet flushed to disk (newly created, + * no prompts yet — pi writes the file lazily on first message) + * + * Disk metadata wins when both exist. Sorted newest-first. + */ + async listSessions(): Promise { + const list: SessionInfo[] = await SessionManager.list(this.projectDir, this.sessionsDir); + const onDisk = new Set(list.map((s) => s.id)); + + const rows: SessionRow[] = list.map((info) => ({ + id: info.id, + createdAt: info.created.toISOString(), + firstMessage: info.firstMessage ?? "", + messageCount: info.messageCount, + })); + + for (const [id, ps] of this.sessions) { + if (onDisk.has(id)) continue; + const messages = ps.session.state.messages as Array<{ + role: string; + content: Array<{ type: string; text?: string }>; + }>; + const firstUser = messages.find((m) => m.role === "user"); + const firstText = firstUser?.content.find((c) => c.type === "text")?.text ?? ""; + rows.push({ + id, + createdAt: ps.boundAt, + firstMessage: firstText, + messageCount: messages.length, + }); + } + + return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + /** + * Permanently delete a session: abort any in-flight run, tear down the + * in-memory ProjectSession (SSE listeners, pending extension UI), and + * remove its persisted JSONL file from disk. + * + * Returns true if a session existed (in memory or on disk) and was + * removed, false if no session with that id was found — letting the + * route map a miss to 404 while keeping the operation idempotent. + * + * Deletion is irreversible: session transcripts are volatile per-developer + * state (never committed to git), so there's no soft-delete tier here. The + * file removal uses `force: true` so a session that was created in memory + * but never flushed to disk doesn't surface a spurious ENOENT. + */ + async deleteSession(id: string): Promise { + const inMemory = this.sessions.get(id); + if (inMemory) { + // Stop any running agent turn before discarding the session so we + // don't leave an orphaned LLM/tool run writing to a deleted file. + await inMemory.abort(); + await inMemory.dispose(); + this.sessions.delete(id); + } + + const list: SessionInfo[] = await SessionManager.list(this.projectDir, this.sessionsDir); + const info = list.find((s) => s.id === id); + if (info) { + await rm(info.path, { force: true }); + return true; + } + + // No file on disk — it existed only if we had it live in memory. + return inMemory !== undefined; + } + + // ── Resource refresh + diagnostics ─────────────────────────────── + + /** + * Reload project resources (skills, extensions, prompts, themes, + * AGENTS.md context) from disk. Existing live sessions keep their + * already-bound extensions; only sessions created after this call + * see the new resources. + * + * Behaviour change vs. pre-services design: previously every + * createNewSession()/getSession() walked the filesystem afresh, so + * skill files added mid-session were picked up automatically. Now + * resources are snapshotted at project startup; call `reload()` + * explicitly to refresh them. + */ + async reload(): Promise { + await this.services.resourceLoader.reload(); + } + + /** + * Non-fatal issues collected during services creation (extension load + * errors, unknown extension flags, custom provider registration + * failures). Live reference to the services bundle's array — not a + * copy. Surface these to operators / API consumers as appropriate. + */ + diagnostics(): readonly AgentSessionRuntimeDiagnostic[] { + return this.services.diagnostics; + } + + // ── Two-step session lookup is the only public API ────────────── + // + // All session-mutating operations live on ProjectSession. Routes do + // `const ps = await runtime.getSession(id)` then call methods on the + // returned ProjectSession directly (e.g. `await ps.sendPrompt(text)`). + // + // ProjectRuntime exposes only the project-level operations: + // createNewSession, getSession, listSessions, reload, diagnostics. +} + +/** Pi's project-tier directory under a project root. */ +const PROJECT_PI_DIR = ".pi"; +/** + * Convention path for the per-project system prompt. + * + * (Used as the auto-`.gitignore` line below to guarantee session + * transcripts never end up committed alongside AGENTS.md / skills / + * extensions, which *do* belong in version control.) + */ +const CONVENTION_AGENTS_FILE = "AGENTS.md"; +/** Convention path for per-project session JSONL storage. */ +const CONVENTION_SESSIONS_DIR = "sessions"; + +/** + * Idempotently write `/.pi/.gitignore` with a single + * `sessions/` line on first runtime construction. + * + * Industry-standard pattern (Next.js writes `.next/.gitignore`, cargo + * writes `target/.gitignore`, Hugging Face writes one inside + * `~/.cache/huggingface/`): a tool that creates a directory inside + * someone's project workspace is responsible for not leaking its own + * volatile output into git. + * + * Why only `sessions/`: + * - `AGENTS.md`, `skills/`, `extensions/` are project resources that + * SHOULD be committed. + * - `settings.json` is debatable — left to the operator. + * - `sessions/` is conversation transcripts. Volume is unbounded, + * contents may include pasted code/API output, and they're + * volatile per-developer state. Never commit. + * + * Strict idempotency: only writes when `.gitignore` is missing. If the + * operator has a custom `.gitignore` already we don't touch it — + * surprise mutation of files in someone's workspace is worse than a + * one-time setup step they can take themselves. + * + * Failures are logged and swallowed. A read-only filesystem or + * permission error here must not block runtime creation — the runtime + * is still functional without a `.gitignore`, the operator just needs + * to add one manually. + */ +function ensureProjectGitignore(projectDir: string, logger: Pick): void { + const piDir = resolve(projectDir, PROJECT_PI_DIR); + const gitignorePath = resolve(piDir, ".gitignore"); + if (existsSync(gitignorePath)) return; + try { + mkdirSync(piDir, { recursive: true }); + writeFileSync( + gitignorePath, + "# Auto-generated by @appx/agent-server. Safe to commit.\n" + + "# Session transcripts are volatile per-developer state — never commit.\n" + + `${CONVENTION_SESSIONS_DIR}/\n`, + { mode: 0o644 }, + ); + logger.log(`[agent] wrote ${gitignorePath} (sessions/ excluded from git)`); + } catch (err) { + logger.error( + `[agent] failed to write ${gitignorePath}: ${String(err)} (continuing; consider adding 'sessions/' to .pi/.gitignore manually)`, + ); + } +} + +/** + * Resolve where session JSONL files live for this runtime. + * + * Industry best practice followed here: convention over configuration. + * Operators set `projectDir` and the layout is derived; the explicit + * override exists only for tests and non-conventional deployments + * (e.g. mounting sessions on a different volume via the config field). + */ +function resolveSessionsDir(config: ProjectRuntimeConfig, projectDir: string): string { + if (config.sessionsDir) { + return isAbsolute(config.sessionsDir) ? config.sessionsDir : resolve(projectDir, config.sessionsDir); + } + return resolve(projectDir, PROJECT_PI_DIR, CONVENTION_SESSIONS_DIR); +} + +/** + * Resolve the agent's system prompt with two-mode semantics: + * + * 1. Explicit override (`config.agentsFile` set): missing file is a + * **fatal** startup error. Preserves "misconfiguration is loud" + * for callers that explicitly point at a path. + * 2. Convention default (`config.agentsFile` unset): falls back to + * `/.pi/AGENTS.md`. Loaded if present, silently + * skipped if absent — the runtime starts with no pinned prompt + * and Pi's normal context-file discovery proceeds. This replaces + * the old `defaultAgentsFile: false` kill switch by making + * "file not present" the natural no-prompt signal for both + * default and per-project runtimes. + */ +function resolveSystemPrompt( + config: ProjectRuntimeConfig, + projectDir: string, + logger: Pick, +): { systemPrompt: string | undefined; agentsFilePath: string | undefined } { + if (config.agentsFile) { + const path = isAbsolute(config.agentsFile) ? config.agentsFile : resolve(projectDir, config.agentsFile); + try { + const systemPrompt = readFileSync(path, "utf8"); + return { systemPrompt, agentsFilePath: path }; + } catch (err) { + logger.error(`[agent] failed to read agentsFile ${path}: ${String(err)}`); + throw err; + } + } + + const conventionPath = resolve(projectDir, PROJECT_PI_DIR, CONVENTION_AGENTS_FILE); + if (!existsSync(conventionPath)) { + return { systemPrompt: undefined, agentsFilePath: undefined }; + } + const systemPrompt = readFileSync(conventionPath, "utf8"); + return { systemPrompt, agentsFilePath: conventionPath }; +} diff --git a/src/runtime/projectSession.ts b/src/runtime/projectSession.ts new file mode 100644 index 0000000..8bdd6b2 --- /dev/null +++ b/src/runtime/projectSession.ts @@ -0,0 +1,502 @@ +/** + * ProjectSession — owns one AgentSession plus all per-session concerns: + * SSE event publishing, extension binding, ExtensionUIContext implementation, + * extension UI request/response routing, and the per-session operations + * (prompt, abort, model/thinking changes, message reads). + * + * Lifecycle: instantiated by ProjectRuntime (currently AgentRuntime, pre-rename) + * when a session is first bound — created via createNewSession or lazily reopened + * via getSession. The constructor immediately subscribes to AgentSession events + * (forwarding to the SSE broker keyed by sessionId) and kicks off bindExtensions. + * Callers can `await extensionsReady` before issuing the first prompt to ensure + * extension `session_start` handlers have run. + * + * Why split from AgentRuntime: every ExtensionUIContext method, every + * pendingExtensionUi entry, and every session-mutating call (prompt, abort, + * setModel, ...) is intrinsically session-scoped. Threading sessionId through + * AgentRuntime methods (createExtensionUiContext(sessionId), pendingExtensionUiRequests(id), + * resolveExtensionUiRequest(id, requestId, response)) was a sign that those + * concerns belong on a per-session class. See + * docs/architecture/project-runtime-and-session-split.md for the full rationale. + * + * What it does NOT do: project-level concerns. It doesn't read project paths, + * doesn't manage the session collection, doesn't construct AgentSessions — + * those stay on ProjectRuntime, which constructs ProjectSession instances and + * passes in the AgentSession plus the small dependency bundle this class needs. + */ + +import { randomUUID } from "node:crypto"; +import type { + AgentSession, + AgentSessionEvent, + CreateAgentSessionOptions, + ExtensionCommandContextActions, + ExtensionUIContext, + ExtensionUIDialogOptions, + ExtensionWidgetOptions, + ModelRegistry, +} from "@earendil-works/pi-coding-agent"; +import { validateAgentSessionEvent } from "../contract/eventValidation.js"; +import type { AgentCredentialsService, AgentModelRow } from "../credentials/credentialsService.js"; +import { publish } from "../http/sseBroker.js"; +import type { ExtensionUiRequest, ExtensionUiResponse } from "../shared/extensionUi.js"; +import { supportedThinkingLevelsForModel, type ThinkingLevel } from "../shared/thinking.js"; + +type SessionModel = NonNullable; + +export type SessionModelSettings = { + model: AgentModelRow | null; + thinkingLevel: ThinkingLevel; + availableThinkingLevels: ThinkingLevel[]; + supportsThinking: boolean; + isStreaming: boolean; +}; + +/** Pending extension-UI request awaiting client response. */ +type PendingExtensionUiRequest = { + request: ExtensionUiRequest; + resolve: (response: ExtensionUiResponse) => void; + timer?: ReturnType; + abort?: () => void; +}; + +/** + * Project-scoped dependencies a ProjectSession needs from its owning + * ProjectRuntime: how to resolve model rows, how to look up models by + * provider/id, and where to log non-fatal errors. We pass them in instead of + * giving ProjectSession a reference to ProjectRuntime so ProjectSession is + * unit-testable in isolation with a tiny stub deps object. + */ +export type ProjectSessionDeps = { + credentials: AgentCredentialsService; + modelRegistry: Pick; + logger: Pick; +}; + +export class ProjectSession { + readonly session: AgentSession; + readonly sessionId: string; + /** When this session was first bound. Fallback createdAt for sessions not yet flushed to disk. */ + readonly boundAt: string; + /** + * Resolves once Pi's bindExtensions() has finished. sendPrompt() awaits + * this so the first prompt sees fully-initialized extensions; SSE + * subscribers don't need to wait for it because events stream as soon as + * they're emitted, regardless of bind completion. + */ + readonly extensionsReady: Promise; + + private readonly deps: ProjectSessionDeps; + private readonly pendingExtensionUi = new Map(); + private readonly unsubscribeEvents: () => void; + private disposed = false; + + constructor(session: AgentSession, deps: ProjectSessionDeps) { + this.session = session; + this.sessionId = session.sessionId; + this.deps = deps; + this.boundAt = new Date().toISOString(); + + // Per-session SSE bridge. The broker routes events by sessionId so + // concurrent sessions in the same project don't cross-talk. Every event + // is validated against the published wire contract on the way out. + this.unsubscribeEvents = session.subscribe((event: AgentSessionEvent) => { + this.publishEvent(event); + this.logRunError(event); + }); + + // Bind extensions with a session-scoped UI context. We keep the promise + // so callers (sendPrompt) can await it before issuing prompts. + this.extensionsReady = session + .bindExtensions({ + uiContext: this.createExtensionUiContext(), + commandContextActions: this.commandActions(), + onError: (err) => { + this.publishEvent({ + type: "extension_error", + extensionPath: err.extensionPath, + event: err.event, + error: err.error, + stack: err.stack, + }); + this.deps.logger.error(`[agent] extension error in ${err.extensionPath}: ${err.error}`); + }, + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + this.publishEvent({ + type: "extension_error", + extensionPath: "", + event: "session_start", + error: message, + }); + this.deps.logger.error(`[agent] extension binding failed for ${this.sessionId}: ${message}`); + }); + } + + // ── Session reads ──────────────────────────────────────────────── + + /** Persisted message history for this session, used to populate the chat UI on reopen. */ + getMessages(): unknown[] { + return this.session.state.messages; + } + + getModelSettings(): SessionModelSettings { + return { + model: this.session.model ? this.deps.credentials.modelRow(this.session.model as SessionModel) : null, + thinkingLevel: this.session.thinkingLevel as ThinkingLevel, + availableThinkingLevels: this.session.getAvailableThinkingLevels() as ThinkingLevel[], + supportsThinking: this.session.supportsThinking(), + isStreaming: this.session.isStreaming, + }; + } + + // ── Session writes ─────────────────────────────────────────────── + + async setModel(provider: string, modelId: string): Promise { + if (this.session.isStreaming) { + throw new Error("Cannot change model while the agent is running"); + } + const model = this.deps.modelRegistry.find(provider, modelId) as SessionModel | undefined; + if (!model) throw new Error(`model ${provider}/${modelId} not found`); + await this.applyModel(model); + return this.getModelSettings(); + } + + setThinkingLevel(level: ThinkingLevel): SessionModelSettings { + if (this.session.isStreaming) { + throw new Error("Cannot change thinking level while the agent is running"); + } + this.session.setThinkingLevel(level); + return this.getModelSettings(); + } + + async updateModelSettings(settings: { + provider?: string; + modelId?: string; + thinkingLevel?: ThinkingLevel; + }): Promise { + if (this.session.isStreaming) { + throw new Error("Cannot change model settings while the agent is running"); + } + if (settings.provider && settings.modelId) { + const model = this.deps.modelRegistry.find(settings.provider, settings.modelId) as SessionModel | undefined; + if (!model) { + throw new Error(`model ${settings.provider}/${settings.modelId} not found`); + } + await this.applyModel(model); + } + if (settings.thinkingLevel) this.session.setThinkingLevel(settings.thinkingLevel); + return this.getModelSettings(); + } + + /** + * Send a user prompt. Events flow over SSE to subscribers. Returns once + * the prompt has been queued; the agent runs asynchronously. + */ + async sendPrompt(text: string): Promise { + await this.extensionsReady; + if (this.session.isStreaming) { + // While the agent is streaming, prompt() requires a streamingBehavior. + // "steer" queues the message for delivery as soon as the current + // assistant turn's tool calls finish — i.e. it actually interrupts + // the agent's plan rather than waiting for it to fully stop + // ("followUp"). Equivalent to session.steer(text). + await this.session.prompt(text, { streamingBehavior: "steer" }); + return; + } + await this.session.prompt(text); + } + + /** + * Abort the current operation (the agent's in-flight LLM call and any + * running tool). Resolves once Pi has torn the run down; the session + * stays usable — subsequent prompts work normally. No-op if not streaming. + */ + async abort(): Promise { + if (!this.session.isStreaming) return; + await this.session.abort(); + } + + // ── Extension UI request routing ───────────────────────────────── + + pendingExtensionUiRequests(): ExtensionUiRequest[] { + return Array.from(this.pendingExtensionUi.values()).map((entry) => entry.request); + } + + resolveExtensionUiRequest(requestId: string, response: ExtensionUiResponse): boolean { + const pending = this.pendingExtensionUi.get(requestId); + if (!pending) return false; + pending.resolve(response); + return true; + } + + // ── Lifecycle ──────────────────────────────────────────────────── + + /** + * Tear down per-session resources: unsubscribe from session events and + * cancel any pending extension UI requests (they resolve with cancelled). + * Currently unused in production — sessions live for the lifetime of the + * runtime — but kept so tests can clean up listeners and so future idle + * eviction has a clean hook. + */ + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.unsubscribeEvents(); + for (const pending of this.pendingExtensionUi.values()) { + if (pending.timer) clearTimeout(pending.timer); + pending.abort?.(); + pending.resolve({ cancelled: true }); + } + this.pendingExtensionUi.clear(); + } + + // ── Private ────────────────────────────────────────────────────── + + /** + * Apply a new model to the session, plus a thinking-level adjustment if + * the new model doesn't support the current level. We use the credentials + * service to find a sensible default for the new model. + */ + private async applyModel(model: SessionModel): Promise { + const currentThinkingLevel = this.session.thinkingLevel as ThinkingLevel; + const nextAvailableLevels = supportedThinkingLevelsForModel(model); + const defaultThinkingLevel = this.deps.credentials.defaultThinkingForModel(model); + const shouldUseModelDefault = Boolean( + defaultThinkingLevel && !nextAvailableLevels.includes(currentThinkingLevel), + ); + await this.session.setModel(model); + if (shouldUseModelDefault && this.session.thinkingLevel !== defaultThinkingLevel) { + this.session.setThinkingLevel(defaultThinkingLevel!); + } + } + + /** + * Command-context actions Pi extensions can invoke. Most session-lifecycle + * actions (newSession, fork, navigateTree, switchSession) are stubbed to + * `cancelled: true` because agent-server doesn't support those flows — + * its multi-session model exposes session creation/switching at the HTTP + * layer, not via in-session extension calls. + */ + private commandActions(): ExtensionCommandContextActions { + return { + waitForIdle: () => this.session.agent.waitForIdle(), + newSession: async () => ({ cancelled: true }), + fork: async () => ({ cancelled: true }), + navigateTree: async () => ({ cancelled: true }), + switchSession: async () => ({ cancelled: true }), + reload: async () => { + await this.session.reload(); + }, + }; + } + + /** + * Build a session-scoped ExtensionUIContext. The `sessionId` is captured + * via `this`, so request-routing happens transparently — every dialog, + * notification, and widget update lands in this session's pending map and + * publishes to this session's SSE channel. + * + * Pattern adapted from Pi's RPC mode (rpc-mode.ts), which does the same + * for stdin/stdout. The structure is identical aside from the implicit + * `this.sessionId` routing replacing RPC's "current session" closure. + */ + private createExtensionUiContext(): ExtensionUIContext { + return { + select: (title, options, opts) => + this.dialog(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) => + "cancelled" in r ? undefined : "value" in r ? r.value : undefined, + ), + confirm: (title, message, opts) => + this.dialog(opts, false, { method: "confirm", title, message, timeout: opts?.timeout }, (r) => + "cancelled" in r ? false : "confirmed" in r ? r.confirmed : false, + ), + input: (title, placeholder, opts) => + this.dialog(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) => + "cancelled" in r ? undefined : "value" in r ? r.value : undefined, + ), + editor: (title, prefill) => + this.dialog(undefined, undefined, { method: "editor", title, prefill }, (r) => + "cancelled" in r ? undefined : "value" in r ? r.value : undefined, + ), + notify: (message, type) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "notify", + message, + notifyType: type, + }), + onTerminalInput: () => () => {}, + setStatus: (key, text) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "setStatus", + statusKey: key, + statusText: text, + }), + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: (( + key: string, + content: string[] | ((...args: unknown[]) => unknown) | undefined, + options?: ExtensionWidgetOptions, + ) => { + if (content !== undefined && !Array.isArray(content)) return; + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "setWidget", + widgetKey: key, + widgetLines: content, + widgetPlacement: options?.placement, + }); + }) as ExtensionUIContext["setWidget"], + setFooter: () => {}, + setHeader: () => {}, + setTitle: (title) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "setTitle", + title, + }), + custom: async () => undefined as never, + pasteToEditor: (text) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "set_editor_text", + text, + }), + setEditorText: (text) => + this.publishRequest({ + type: "extension_ui_request", + id: randomUUID(), + method: "set_editor_text", + text, + }), + getEditorText: () => "", + addAutocompleteProvider: () => {}, + setEditorComponent: () => {}, + getEditorComponent: () => undefined, + get theme() { + return undefined as never; + }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: () => ({ + success: false, + error: "UI theme switching is not available in agent-server", + }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, + }; + } + + /** + * Promise-based dialog flow with timeout and AbortSignal support. + * + * Adapted from Pi's RPC mode `createDialogPromise` helper. Differences: + * 1. No sessionId argument — `this.sessionId` is captured implicitly. + * 2. Publishes via SSE broker instead of stdout JSON lines. + * + * Caller responsibility: + * - Pass `fallback` matching the dialog's "cancelled" return (e.g. false + * for confirm, undefined for select/input/editor). The fallback is + * also returned on timeout and on abort-signal triggering. + * - `mapResponse` translates the wire ExtensionUiResponse into the + * domain return type expected by the calling extension API. + */ + private dialog( + opts: ExtensionUIDialogOptions | undefined, + fallback: T, + request: Record, + mapResponse: (response: ExtensionUiResponse) => T, + ): Promise { + const id = randomUUID(); + const event = { type: "extension_ui_request" as const, id, ...request } as ExtensionUiRequest; + + return new Promise((resolve) => { + const finish = (response: ExtensionUiResponse) => { + const pending = this.pendingExtensionUi.get(id); + if (!pending) return; + if (pending.timer) clearTimeout(pending.timer); + pending.abort?.(); + this.pendingExtensionUi.delete(id); + resolve(mapResponse(response)); + }; + + const pending: PendingExtensionUiRequest = { + request: event, + resolve: finish, + }; + + if (opts?.timeout && opts.timeout > 0) { + pending.timer = setTimeout(() => finish({ cancelled: true }), opts.timeout); + } + + if (opts?.signal) { + const onAbort = () => finish({ cancelled: true }); + opts.signal.addEventListener("abort", onAbort, { once: true }); + pending.abort = () => opts.signal?.removeEventListener("abort", onAbort); + } + + this.pendingExtensionUi.set(id, pending); + this.publishRequest(event); + + // fallback only used by linter; route closures use it via the + // timeout/signal paths above. Mark as referenced. + void fallback; + }); + } + + private publishRequest(request: ExtensionUiRequest): void { + this.publishEvent(request); + } + + /** + * Validate an outgoing event against the published SSE wire contract, then + * forward it to the broker regardless of the outcome. + * + * Validation is observe-only — the SSE stream must stay robust, so we never + * drop an event. A known type with a broken shape is a real contract + * violation (logged loudly); an unrecognised type means pi added an event we + * don't model yet (logged softly) and is forward-compatible by design. + */ + private publishEvent(event: unknown): void { + const validation = validateAgentSessionEvent(event); + if (validation.status === "invalid") { + this.deps.logger.error( + `[agent] SSE event failed wire-schema validation (session=${this.sessionId}): ${validation.issues}`, + ); + } else if (validation.status === "unknown-type") { + this.deps.logger.log( + `[agent] forwarding unmodeled SSE event type='${validation.type}' (session=${this.sessionId}); wire contract may be stale`, + ); + } + publish(this.sessionId, event); + } + + /** + * Log a provider/run failure to the server console. Pi surfaces an LLM error + * as a *normal* run that ends with an assistant message whose `stopReason` is + * `"error"` (carrying `errorMessage`) — it does not throw, so `sendPrompt()` + * resolves and the failure would otherwise be invisible server-side. We log it + * once, when the run ends and is not going to auto-retry, so a silent failure + * leaves a trace in the logs (the error is also forwarded over SSE for the UI). + */ + private logRunError(event: AgentSessionEvent): void { + if (event.type !== "agent_end" || event.willRetry) return; + const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant"); + if (lastAssistant?.role === "assistant" && lastAssistant.stopReason === "error") { + this.deps.logger.error( + `[agent] run error (session=${this.sessionId}): ${lastAssistant.errorMessage ?? "unknown error"}`, + ); + } + } +} diff --git a/src/runtime/projectStore.ts b/src/runtime/projectStore.ts new file mode 100644 index 0000000..30cdfdc --- /dev/null +++ b/src/runtime/projectStore.ts @@ -0,0 +1,124 @@ +/** + * ProjectStore — durable, on-disk registry of project *metadata*. + * + * This is the **source of truth** for "which projects exist" and survives + * agent-server / container restarts (the file lives on the mounted workspace + * volume at `WORKSPACE_DIR/.pi-global/projects.json`). On boot the + * ProjectRegistry rehydrates from it; runtimes themselves are rebuilt lazily. + * + * Scope boundary: this stores only agent-server-owned identity/metadata + * (`id`, `name`, `createdAt`). It is *not* a Pi SDK file — `AuthStorage` / + * `ModelRegistry` do not read it. App/agent domain state (game inventories, + * etc.) belongs to the consuming app's own database, not here. + * + * Concurrency: agent-server is a single process, so there is one writer. Writes + * are nonetheless atomic (temp file + `rename`) so a crash mid-write can never + * leave a half-written, unparseable registry. + * + * See docs/architecture/project-lifecycle-and-workspace-layout.md. + */ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Persisted, agent-server-owned metadata for one project. */ +export type ProjectRecord = { + /** Immutable slug; registry key, route param, and on-disk directory name. */ + id: string; + /** Mutable, human-facing display label. Never used to build paths. */ + name: string; + /** ISO-8601 creation timestamp. */ + createdAt: string; +}; + +/** On-disk envelope. Versioned so the schema can evolve without ambiguity. */ +type ProjectStoreFile = { + version: 1; + projects: ProjectRecord[]; +}; + +const STORE_VERSION = 1 as const; + +/** + * File-backed map of `id -> ProjectRecord`. Construct via `ProjectStore.load`, + * which reads (or initialises) the JSON file. All mutations persist + * synchronously and atomically. + */ +export class ProjectStore { + private readonly filePath: string; + private readonly records = new Map(); + + private constructor(filePath: string, records: ProjectRecord[]) { + this.filePath = filePath; + for (const record of records) this.records.set(record.id, record); + } + + /** + * Load the store from `filePath`, creating an empty registry if the file is + * absent. A present-but-corrupt file is a fatal error rather than silently + * discarded — losing the project registry should be loud, not implicit. + */ + static load(filePath: string): ProjectStore { + if (!existsSync(filePath)) { + mkdirSync(dirname(filePath), { recursive: true }); + return new ProjectStore(filePath, []); + } + const raw = readFileSync(filePath, "utf8"); + let parsed: ProjectStoreFile; + try { + parsed = JSON.parse(raw) as ProjectStoreFile; + } catch (err) { + throw new Error(`corrupt projects registry at ${filePath}: ${String(err)}`); + } + if (parsed.version !== STORE_VERSION || !Array.isArray(parsed.projects)) { + throw new Error(`unsupported projects registry shape at ${filePath}`); + } + return new ProjectStore(filePath, parsed.projects); + } + + /** True if a project with this id is registered. */ + has(id: string): boolean { + return this.records.has(id); + } + + /** Return one record, or undefined if unknown. */ + get(id: string): ProjectRecord | undefined { + return this.records.get(id); + } + + /** All records, newest first. */ + list(): ProjectRecord[] { + return [...this.records.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } + + /** + * Insert a new record and persist. Throws if the id already exists — callers + * implementing idempotent upsert should check `has()` first and return the + * existing record rather than calling this. + */ + add(record: ProjectRecord): ProjectRecord { + if (this.records.has(record.id)) { + throw new Error(`project already exists: ${record.id}`); + } + this.records.set(record.id, record); + this.persist(); + return record; + } + + /** Remove a record and persist. No-op if the id is unknown. */ + remove(id: string): void { + if (this.records.delete(id)) this.persist(); + } + + /** Atomically write the registry to disk (temp file + rename). */ + private persist(): void { + const payload: ProjectStoreFile = { + version: STORE_VERSION, + projects: this.list(), + }; + const tmpPath = join(dirname(this.filePath), `.projects.${process.pid}.${Date.now()}.tmp`); + writeFileSync(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, { + mode: 0o644, + }); + renameSync(tmpPath, this.filePath); + } +} diff --git a/src/schemas.ts b/src/schemas.ts deleted file mode 100644 index 1b44cbc..0000000 --- a/src/schemas.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Zod schemas for the agent-server REST API. - * - * These are the source of truth for: - * - request/response validation at runtime (via @hono/zod-openapi) - * - the OpenAPI 3.1 document published at /openapi.json - * - generated TypeScript types for consumers (eventx-backend uses - * `openapi-typescript` against the published openapi.json) - * - * The pi-shaped AgentSessionEvent on the SSE stream is intentionally not - * fully modeled here. Pi owns that contract; locking it down in two places - * would drift. The SSE endpoint is documented in OpenAPI but typed loosely - * (string content under `text/event-stream`); consumers parse `data:` JSON - * payloads using their own knowledge of pi's event shape. - */ -import { z } from "@hono/zod-openapi"; - -/** A row in the sessions list. Stable shape across all consuming apps. */ -export const SessionRowSchema = z - .object({ - id: z.string().openapi({ example: "01J9Z..." }), - createdAt: z.string().openapi({ - example: "2026-05-17T10:00:00.000Z", - description: "ISO-8601 UTC timestamp", - }), - firstMessage: z.string().openapi({ - description: "First user message; empty for never-prompted sessions.", - }), - messageCount: z.number().int().nonnegative(), - }) - .openapi("SessionRow"); - -export const ListSessionsResponseSchema = z - .object({ - sessions: z.array(SessionRowSchema), - }) - .openapi("ListSessionsResponse"); - -export const CreateSessionResponseSchema = z - .object({ - id: z.string(), - createdAt: z.string(), - }) - .openapi("CreateSessionResponse"); - -/** - * Pi message shape is rich (roles toolCall / toolResult, content parts, - * tool ids, etc.). We forward whatever pi has persisted; the consumer - * frontend interprets it. Documented as opaque objects to keep this - * server's contract decoupled from pi's internal evolution. - */ -export const SessionMessagesResponseSchema = z - .object({ - id: z.string(), - messages: z.array(z.unknown()).openapi({ - description: "Pi-shaped message objects (role + content array). Opaque here.", - }), - }) - .openapi("SessionMessagesResponse"); - -export const PromptRequestSchema = z - .object({ - text: z.string().min(1).openapi({ example: "find me events this weekend" }), - }) - .openapi("PromptRequest"); - -export const OkResponseSchema = z - .object({ - ok: z.literal(true), - }) - .openapi("OkResponse"); - -export const ErrorResponseSchema = z - .object({ - error: z.string(), - }) - .openapi("ErrorResponse"); - -export const HealthResponseSchema = z - .object({ - ok: z.literal(true), - service: z.literal("agent-server"), - time: z.string(), - channels: z.record(z.number()).openapi({ - description: "Map of SSE channel name → current subscriber count.", - }), - }) - .openapi("HealthResponse"); - -export const SessionIdParamSchema = z.object({ - id: z.string().min(1).openapi({ param: { name: "id", in: "path" } }), -}); diff --git a/src/server.ts b/src/server.ts index 75e61d5..2b6878d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,72 +1,84 @@ +#!/usr/bin/env node /** * Standalone agent-server entrypoint. * - * Single-tenant model: one process per Appx app. Configuration is read - * from environment variables, the AgentRuntime is instantiated once, and - * the Hono app is served via @hono/node-server. Bind to 127.0.0.1 by - * default — the eventx-backend (and any other intra-app caller) reaches - * us over loopback. + * Routing is always project-scoped. Shared Pi auth/model state lives under + * `WORKSPACE_DIR/.pi-global/`; projects are explicit, persisted resources + * created via `POST /v1/projects` and addressed at + * `/v1/projects/:projectId/...`. Bind to 127.0.0.1 by default so app backends + * reach us over loopback. * - * Required env: - * PROJECT_DIR cwd handed to pi (skill discovery rooted here) + * Configuration is loaded from environment variables; see `./config.ts` + * for the full schema, defaults, and validation rules. The OpenAPI doc + * is published at /openapi.json and Swagger UI at /docs. * - * Optional env: - * SESSIONS_DIR where pi writes session JSONL files - * (default: /data/sessions) - * AGENTS_FILE path to system-prompt markdown, relative to - * PROJECT_DIR or absolute (default: .pi/AGENTS.md) - * ANTHROPIC_API_KEY injected into pi's AuthStorage if set - * AGENT_SERVER_HOST bind host (default: 127.0.0.1) - * AGENT_SERVER_PORT bind port (default: 4001) - * AGENT_SERVER_TOKEN if set, /v1/* requires `Authorization: Bearer ` - * - * The OpenAPI doc is published at /openapi.json and Swagger UI at /docs. + * See docs/architecture/project-lifecycle-and-workspace-layout.md. */ -import { existsSync } from "node:fs"; -import { isAbsolute, resolve } from "node:path"; import { serve } from "@hono/node-server"; import { swaggerUI } from "@hono/swagger-ui"; import { OpenAPIHono } from "@hono/zod-openapi"; -import { AgentRuntime } from "./runtime.js"; -import { createSessionsApp } from "./routes.js"; +import type { Context } from "hono"; +import { ConfigError, loadConfig, type ServerConfig } from "./config.js"; +import { buildOpenApiDocument } from "./contract/openapiEventSchema.js"; +import { createCredentialsApp } from "./http/credentialsRoutes.js"; +import { createProjectsApp } from "./http/projectsRoutes.js"; +import { createSessionsApp } from "./http/sessionsRoutes.js"; +import { litellmRuntimeConfig, logLiteLlmStartupConfig } from "./providers/litellm.js"; +import { ProjectRegistry } from "./runtime/projectRegistry.js"; +import type { ProjectRuntime } from "./runtime/projectRuntime.js"; -function required(name: string): string { - const v = process.env[name]; - if (!v || !v.trim()) { - console.error(`[agent-server] missing required env var: ${name}`); - process.exit(2); +let config: ServerConfig; +try { + config = loadConfig(); +} catch (err) { + if (err instanceof ConfigError) { + console.error(`[agent-server] ${err.message}`); + } else { + console.error("[agent-server] failed to load configuration:", err); } - return v; -} - -function optional(name: string, fallback: string): string { - const v = process.env[name]; - return v && v.trim() ? v : fallback; -} - -const projectDir = resolve(required("PROJECT_DIR")); -if (!existsSync(projectDir)) { - console.error(`[agent-server] PROJECT_DIR does not exist: ${projectDir}`); process.exit(2); } -const sessionsDirRaw = optional("SESSIONS_DIR", resolve(projectDir, "data/sessions")); -const sessionsDir = isAbsolute(sessionsDirRaw) - ? sessionsDirRaw - : resolve(projectDir, sessionsDirRaw); +logLiteLlmStartupConfig(); -const agentsFile = optional("AGENTS_FILE", ".pi/AGENTS.md"); +const projectRegistry = await ProjectRegistry.create({ + workspaceDir: config.workspaceDir, + anthropicApiKey: config.anthropicApiKey, + extensionPaths: config.extensionPaths, + skillPaths: config.skillPaths, + promptTemplatePaths: config.promptTemplatePaths, + themePaths: config.themePaths, + noExtensions: config.noExtensions, + noSkills: config.noSkills, + noPromptTemplates: config.noPromptTemplates, + noThemes: config.noThemes, + ...litellmRuntimeConfig(), +}); -const host = optional("AGENT_SERVER_HOST", "127.0.0.1"); -const port = Number(optional("AGENT_SERVER_PORT", "4001")); -const token = process.env.AGENT_SERVER_TOKEN?.trim(); +/** Raised when a session request targets a project that was never created. */ +class ProjectNotRegisteredError extends Error { + constructor(projectId: string) { + super(projectId ? `project not registered: ${projectId}` : "project id required"); + this.name = "ProjectNotRegisteredError"; + } +} -const runtime = new AgentRuntime({ - projectDir, - sessionsDir, - agentsFile, - anthropicApiKey: process.env.ANTHROPIC_API_KEY, -}); +/** + * Resolve the ProjectRuntime for a session request by its path `projectId`. + * + * Pure lookup against the registry — the project must already have been created + * via `POST /v1/projects`. An unknown id throws `ProjectNotRegisteredError`, + * which the global error handler maps to 404. This replaces the old + * header-driven lazy creation: project definition no longer rides on every + * request. + */ +async function projectRuntimeFromRequest(c: Context): Promise { + const projectId = c.req.param("projectId")?.trim(); + if (!projectId) throw new ProjectNotRegisteredError(""); + const runtime = await projectRegistry.getRuntime(projectId); + if (!runtime) throw new ProjectNotRegisteredError(projectId); + return runtime; +} const root = new OpenAPIHono(); @@ -75,11 +87,12 @@ const root = new OpenAPIHono(); * env. The seam exists so production deployments can flip auth on * without code changes; in single-user dev, leave it unset. */ -if (token) { +if (config.token) { + const expectedToken = config.token; root.use("/v1/*", async (c, next) => { const auth = c.req.header("authorization") ?? ""; const presented = auth.startsWith("Bearer ") ? auth.slice(7) : ""; - if (presented !== token) { + if (presented !== expectedToken) { return c.json({ error: "unauthorized" }, 401); } await next(); @@ -89,21 +102,37 @@ if (token) { console.log("[agent-server] AGENT_SERVER_TOKEN unset — /v1/* is open (loopback only)"); } -// Mount the versioned API under /v1. -root.route("/v1", createSessionsApp(runtime)); +root.onError((err, c) => { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof ProjectNotRegisteredError) { + return c.json({ error: message }, 404); + } + console.error("[agent-server] request failed:", err); + return c.json({ error: "internal server error" }, 500); +}); + +// Mount the versioned API under /v1. Shared auth/custom-provider routes plus +// project lifecycle management live at /v1; session runtimes are addressed per +// project under /v1/projects/:projectId. +root.route("/v1", createCredentialsApp(projectRegistry.credentials)); +root.route("/v1", createProjectsApp(projectRegistry)); +root.route("/v1/projects/:projectId", createSessionsApp(projectRuntimeFromRequest)); // OpenAPI document + Swagger UI. Doc lives at /openapi.json so consumers -// (eventx-backend) can fetch it for codegen at build time. -root.doc("/openapi.json", { - openapi: "3.1.0", - info: { - title: "Appx Agent Server", - version: "0.1.0", - description: - "Pi-SDK-based agent orchestration. Single-tenant per process; one instance per Appx app.", - }, - servers: [{ url: `http://${host}:${port}`, description: "local" }], -}); +// (eventx-backend) can fetch it for codegen at build time. Built via the shared +// `buildOpenApiDocument` (same source as the static dump) so they can't drift; +// the live doc additionally advertises this server's address. Computed once and +// cached. +let openApiDocCache: unknown; +const buildOpenApiDoc = () => { + if (!openApiDocCache) { + openApiDocCache = buildOpenApiDocument(root, { + servers: [{ url: `http://${config.host}:${config.port}`, description: "local" }], + }); + } + return openApiDocCache; +}; +root.get("/openapi.json", (c) => c.json(buildOpenApiDoc() as object)); root.get("/docs", swaggerUI({ url: "/openapi.json" })); @@ -115,12 +144,29 @@ root.get("/", (c) => docs: "/docs", openapi: "/openapi.json", v1: "/v1", + projects: "/v1/projects", + sessions: "/v1/projects/:projectId/sessions", }), ); -serve({ fetch: root.fetch, hostname: host, port }, (info) => { +serve({ fetch: root.fetch, hostname: config.host, port: config.port }, (info) => { console.log(`[agent-server] listening on http://${info.address}:${info.port}`); - console.log(`[agent-server] projectDir=${projectDir}`); - console.log(`[agent-server] sessionsDir=${sessionsDir}`); - console.log(`[agent-server] agentsFile=${agentsFile}`); + // Filesystem layout: everything lives under WORKSPACE_DIR. Org-shared + // auth.json/models.json plus the durable projects.json registry and + // session transcripts live in WORKSPACE_DIR/.pi-global/; each project's + // config tier is WORKSPACE_DIR//.pi/. + console.log(`[agent-server] workspaceDir=${config.workspaceDir}`); + console.log(`[agent-server] globalDir=${config.workspaceDir}/.pi-global`); + if (config.extensionPaths.length) { + console.log(`[agent-server] PI_EXTENSION_PATHS=${config.extensionPaths.join(",")}`); + } + if (config.skillPaths.length) { + console.log(`[agent-server] PI_SKILL_PATHS=${config.skillPaths.join(",")}`); + } + if (config.promptTemplatePaths.length) { + console.log(`[agent-server] PI_PROMPT_PATHS=${config.promptTemplatePaths.join(",")}`); + } + if (config.themePaths.length) { + console.log(`[agent-server] PI_THEME_PATHS=${config.themePaths.join(",")}`); + } }); diff --git a/src/shared/extensionUi.ts b/src/shared/extensionUi.ts new file mode 100644 index 0000000..c4ba2ab --- /dev/null +++ b/src/shared/extensionUi.ts @@ -0,0 +1,55 @@ +/** + * Extension UI request/response types for SSE transport. + * + * These mirror Pi's `RpcExtensionUIRequest` / `RpcExtensionUIResponse` from + * `@earendil-works/pi-coding-agent/modes/rpc`, but kept locally because Pi + * doesn't export them from its public API. + * + * @see https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/modes/rpc/rpc-types.ts + */ + +import type { WidgetPlacement } from "@earendil-works/pi-coding-agent"; + +export type ExtensionUiRequest = + | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number } + | { + type: "extension_ui_request"; + id: string; + method: "input"; + title: string; + placeholder?: string; + timeout?: number; + } + | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } + | { + type: "extension_ui_request"; + id: string; + method: "notify"; + message: string; + notifyType?: "info" | "warning" | "error"; + } + | { + type: "extension_ui_request"; + id: string; + method: "setStatus"; + statusKey: string; + statusText: string | undefined; + } + | { + type: "extension_ui_request"; + id: string; + method: "setWidget"; + widgetKey: string; + widgetLines: string[] | undefined; + widgetPlacement?: WidgetPlacement; + } + | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } + | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }; + +/** + * Simplified from Pi's `RpcExtensionUIResponse` — we omit the `type` and + * `id` fields because the resolver already knows which request this + * responds to (via the URL `requestId` path parameter). + */ +export type ExtensionUiResponse = { value: string } | { confirmed: boolean } | { cancelled: true }; diff --git a/src/shared/thinking.ts b/src/shared/thinking.ts new file mode 100644 index 0000000..8fa2e78 --- /dev/null +++ b/src/shared/thinking.ts @@ -0,0 +1,24 @@ +/** + * Thin wrapper over Pi's thinking-level helpers. + * + * Pi owns the canonical clamp + supported-levels logic in + * `@earendil-works/pi-ai/models.ts`. We re-export them under + * agent-server-friendly names and a `Pick`-style type so callers can + * pass either a real Pi `Model` or a partial { reasoning, thinkingLevelMap } + * shape (used by litellm config validation). + */ +import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai"; + +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; + +type ThinkingLevelInput = Pick, "reasoning" | "thinkingLevelMap">; + +export function supportedThinkingLevelsForModel(model: ThinkingLevelInput): ThinkingLevel[] { + return getSupportedThinkingLevels(model as Model) as ThinkingLevel[]; +} + +export function clampThinkingLevelForModel(model: ThinkingLevelInput, level: ThinkingLevel): ThinkingLevel { + return clampThinkingLevel(model as Model, level) as ThinkingLevel; +} diff --git a/src/utils/slug.ts b/src/utils/slug.ts new file mode 100644 index 0000000..0dea5b1 --- /dev/null +++ b/src/utils/slug.ts @@ -0,0 +1,54 @@ +/** + * Project id (slug) derivation. + * + * A project's `id` is the canonical, URL-safe, filesystem-safe identifier + * derived from its human-facing `name`. It is immutable once created and is + * used simultaneously as the registry key, the route path parameter, and the + * on-disk directory name under `WORKSPACE_DIR/`. See + * docs/architecture/project-lifecycle-and-workspace-layout.md. + * + * Security note (OWASP path traversal): because the only filesystem-bound input + * is a slugified name, callers cannot smuggle `..` or absolute paths to escape + * the workspace root — `slugify` only ever emits `[a-z0-9-]`. + */ + +/** Directory name reserved for agent-server's org-global state; never a project id. */ +export const RESERVED_PROJECT_SLUGS: ReadonlySet = new Set([".pi-global"]); + +/** Max slug length, mirroring the appx project-name grammar so ids stay aligned. */ +const MAX_SLUG_LENGTH = 63; + +/** + * Convert a human project name into a slug. + * + * Lowercases, replaces any run of non-alphanumeric characters with a single + * hyphen, and trims leading/trailing hyphens. Returns an empty string when the + * name has no usable characters — callers must treat that as invalid. + */ +export function slugify(name: string): string { + return name + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") // strip diacritics + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_SLUG_LENGTH) + .replace(/-+$/g, ""); // re-trim if the slice landed on a hyphen +} + +/** A slug is usable if it is non-empty and not a reserved directory name. */ +export function isValidProjectSlug(slug: string): boolean { + return slug.length > 0 && !RESERVED_PROJECT_SLUGS.has(slug); +} + +/** + * Append a short random suffix to disambiguate a colliding slug, e.g. + * `my-app` -> `my-app-7f3a`. Kept short (4 hex chars) for readable directory + * names; collisions on the suffix itself are handled by the caller retrying. + */ +export function withCollisionSuffix(slug: string): string { + const suffix = Math.floor(Math.random() * 0xffff) + .toString(16) + .padStart(4, "0"); + return `${slug.slice(0, MAX_SLUG_LENGTH - 5)}-${suffix}`; +} diff --git a/test/credentialsService.test.ts b/test/credentialsService.test.ts new file mode 100644 index 0000000..f87c430 --- /dev/null +++ b/test/credentialsService.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { after, before, describe, test } from "node:test"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { AgentCredentialsService } from "../src/credentials/credentialsService.js"; + +function makeAgentDir(): { dir: string; cleanup: () => void } { + const dir = mkdtempSync(resolve(tmpdir(), "agent-server-creds-")); + mkdirSync(dir, { recursive: true }); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe("AgentCredentialsService", () => { + let agent: { dir: string; cleanup: () => void }; + + before(() => { + agent = makeAgentDir(); + }); + + after(() => { + agent.cleanup(); + }); + + test("constructor requires authStorage and modelRegistry references", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + assert.equal(typeof service.listAuthProviders, "function"); + }); + + test("listModels returns Pi-shaped rows with availability flag", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + authStorage.set("anthropic", { type: "api_key", key: "sk-ant-test" }); + modelRegistry.refresh(); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + + const models = service.listModels(); + const anthropic = models.find((m) => m.provider === "anthropic"); + assert.ok(anthropic, "expected at least one anthropic model"); + assert.equal(anthropic!.available, true); + assert.equal(typeof anthropic!.contextWindow, "number"); + }); + + test("setProviderApiKey persists, listAuthProviders shows configured, removeProviderCredential clears", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + + service.setProviderApiKey("anthropic", "sk-ant-test"); + let providers = service.listAuthProviders(); + let anthropic = providers.find((p) => p.provider === "anthropic"); + assert.equal(anthropic?.configured, true); + assert.equal(anthropic?.source, "stored"); + + service.removeProviderCredential("anthropic"); + providers = service.listAuthProviders(); + anthropic = providers.find((p) => p.provider === "anthropic"); + // remaining anthropic row reflects no stored credential + assert.notEqual(anthropic?.source, "stored"); + }); + + test("setProviderApiKey rejects malformed provider id", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + assert.throws(() => service.setProviderApiKey("bad provider!", "k"), /invalid provider id/); + }); + + test("startProviderSubscriptionLogin reuses an active flow", async () => { + let loginCalls = 0; + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + modelRegistry.registerProvider("test-reuse", { + name: "Test Reuse", + baseUrl: "https://example.test/v1", + api: "openai-completions", + oauth: { + name: "Test Reuse", + login: async (callbacks: any) => { + loginCalls += 1; + callbacks.onAuth?.({ url: "https://login.example.test/", instructions: "x" }); + await callbacks.onManualCodeInput?.(); + return { access: "tok", refresh: "rfr", expires: Date.now() + 60_000 }; + }, + refreshToken: async (c: any) => c, + getApiKey: (c: any) => c.access, + }, + models: [ + { + id: "m", + name: "M", + api: "openai-completions", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 1024, + }, + ], + }); + + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + + const first = await service.startProviderSubscriptionLogin("test-reuse"); + const second = await service.startProviderSubscriptionLogin("test-reuse"); + assert.equal(second.id, first.id); + assert.equal(loginCalls, 1); + + const cancelled = service.cancelProviderSubscriptionLogin(first.id); + assert.equal(cancelled?.status, "cancelled"); + }); + + test("upsertCustomProvider writes models.json with 0600 perms and registers in ModelRegistry", () => { + const authStorage = AuthStorage.create(resolve(agent.dir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agent.dir, "models.json")); + const service = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agent.dir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + + const row = service.upsertCustomProvider({ + provider: "litellm-test", + name: "LiteLLM Test", + baseUrl: "http://litellm.test/v1", + api: "openai-completions", + apiKey: "test-secret", + models: [ + { + id: "test-model", + name: "Test", + api: "openai-completions", + reasoning: false, + input: ["text"], + contextWindow: 4096, + maxTokens: 1024, + }, + ], + }); + assert.equal(row.provider, "litellm-test"); + assert.equal(row.apiKeyConfigured, true); + assert.equal(row.modelCount, 1); + + const listed = service.listCustomProviders(); + assert.ok(listed.some((p) => p.provider === "litellm-test")); + + service.removeCustomProvider("litellm-test"); + assert.equal( + service.listCustomProviders().some((p) => p.provider === "litellm-test"), + false, + ); + }); +}); diff --git a/test/eventSchema.test.ts b/test/eventSchema.test.ts new file mode 100644 index 0000000..5b7aa4a --- /dev/null +++ b/test/eventSchema.test.ts @@ -0,0 +1,135 @@ +/** + * Tests for the generated SSE wire-event contract. + * + * Two layers: + * - The runtime classifier (`validateAgentSessionEvent`) — shallow, tolerant. + * - Deep validation against the generated JSON Schema with ajv, over curated + * *complete* fixtures. This is the drift guard: if a regeneration changes a + * committed shape, these fail. (Runtime stays shallow on purpose; deep checks + * here avoid false alarms on streaming partial messages.) + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, test } from "node:test"; +import Ajv2020 from "ajv/dist/2020.js"; +import { KNOWN_AGENT_SESSION_EVENT_TYPES, validateAgentSessionEvent } from "../src/contract/eventValidation.js"; + +const generated = JSON.parse( + readFileSync(new URL("../src/contract/eventSchema.generated.json", import.meta.url), "utf8"), +); + +const ajv = new Ajv2020({ strict: false, allErrors: true }); +ajv.addSchema(generated, "wire"); +const validateWire = ajv.getSchema("wire#/components/schemas/WireEvent"); + +describe("runtime classifier (validateAgentSessionEvent)", () => { + test("a committed event type is valid", () => { + assert.deepEqual(validateAgentSessionEvent({ type: "agent_start" }), { status: "valid" }); + assert.deepEqual(validateAgentSessionEvent({ type: "extension_ui_request", id: "r1" }), { + status: "valid", + }); + }); + + test("an unmodeled type is unknown-type (forward-compatible), not invalid", () => { + assert.deepEqual(validateAgentSessionEvent({ type: "some_future_event" }), { + status: "unknown-type", + type: "some_future_event", + }); + }); + + test("never throws on malformed input", () => { + assert.equal(validateAgentSessionEvent(null).status, "invalid"); + assert.equal(validateAgentSessionEvent("nope").status, "invalid"); + assert.equal(validateAgentSessionEvent({}).status, "invalid"); + assert.equal(validateAgentSessionEvent({ type: 9 }).status, "invalid"); + }); +}); + +describe("known-type set is derived from the generated schema", () => { + test("covers every documented event type", () => { + for (const expected of [ + "agent_start", + "agent_end", + "turn_start", + "turn_end", + "message_start", + "message_update", + "message_end", + "tool_execution_start", + "tool_execution_update", + "tool_execution_end", + "queue_update", + "compaction_start", + "compaction_end", + "session_info_changed", + "thinking_level_changed", + "auto_retry_start", + "auto_retry_end", + "extension_ui_request", + "extension_error", + ]) { + assert.ok(KNOWN_AGENT_SESSION_EVENT_TYPES.has(expected), `expected wire contract to cover '${expected}'`); + } + }); +}); + +describe("generated JSON Schema (ajv deep validation)", () => { + test("the schema compiles (no dangling $refs)", () => { + assert.equal(typeof validateWire, "function"); + }); + + const validEvents: Array<{ name: string; event: unknown }> = [ + { name: "agent_start", event: { type: "agent_start" } }, + { name: "turn_start", event: { type: "turn_start" } }, + { name: "agent_end", event: { type: "agent_end", messages: [], willRetry: false } }, + { name: "queue_update", event: { type: "queue_update", steering: [], followUp: [] } }, + { name: "thinking_level_changed", event: { type: "thinking_level_changed", level: "high" } }, + { + name: "tool_execution_start", + event: { type: "tool_execution_start", toolCallId: "t1", toolName: "bash", args: { command: "ls" } }, + }, + { + name: "tool_execution_end", + event: { + type: "tool_execution_end", + toolCallId: "t1", + toolName: "bash", + result: { ok: true }, + isError: false, + }, + }, + { + name: "extension_ui_request/confirm", + event: { type: "extension_ui_request", id: "r1", method: "confirm", title: "Proceed?", message: "..." }, + }, + { + name: "extension_error", + event: { type: "extension_error", extensionPath: "ext.js", error: "boom" }, + }, + ]; + + for (const { name, event } of validEvents) { + test(`accepts a complete ${name}`, () => { + const ok = validateWire!(event); + assert.ok(ok, `expected ${name} to validate; errors: ${JSON.stringify(validateWire!.errors)}`); + }); + } + + const invalidEvents: Array<{ name: string; event: unknown }> = [ + { + name: "tool_execution_end missing isError", + event: { type: "tool_execution_end", toolCallId: "t1", toolName: "bash", result: {} }, + }, + { + name: "extension_ui_request/confirm missing message", + event: { type: "extension_ui_request", id: "r1", method: "confirm", title: "Proceed?" }, + }, + { name: "unknown event type", event: { type: "some_future_event" } }, + ]; + + for (const { name, event } of invalidEvents) { + test(`rejects ${name}`, () => { + assert.equal(validateWire!(event), false); + }); + } +}); diff --git a/test/projectLifecycle.test.ts b/test/projectLifecycle.test.ts new file mode 100644 index 0000000..f89ef87 --- /dev/null +++ b/test/projectLifecycle.test.ts @@ -0,0 +1,223 @@ +/** + * Unit tests for the project lifecycle layer: slug derivation, the durable + * ProjectStore, and the ProjectRegistry's create/idempotency/rehydration and + * removal behaviour. No HTTP, no LLM calls. + */ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, test } from "node:test"; +import { ProjectRegistry } from "../src/runtime/projectRegistry.js"; +import { ProjectStore } from "../src/runtime/projectStore.js"; +import { isValidProjectSlug, RESERVED_PROJECT_SLUGS, slugify, withCollisionSuffix } from "../src/utils/slug.js"; + +const silentLogger = { log: () => {}, error: () => {} }; + +function makeWorkspace(): { dir: string; cleanup: () => void } { + const dir = mkdtempSync(resolve(tmpdir(), "agent-server-lifecycle-")); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +describe("slugify", () => { + test("lowercases, hyphenates, and trims", () => { + assert.equal(slugify("My Cool App"), "my-cool-app"); + assert.equal(slugify(" Trim__me!! "), "trim-me"); + assert.equal(slugify("Already-A-Slug"), "already-a-slug"); + }); + + test("strips diacritics", () => { + assert.equal(slugify("Café Münchén"), "cafe-munchen"); + }); + + test("yields empty string for unusable names", () => { + assert.equal(slugify(" "), ""); + assert.equal(slugify("!!!"), ""); + }); + + test("isValidProjectSlug rejects empty and reserved slugs", () => { + assert.equal(isValidProjectSlug("my-app"), true); + assert.equal(isValidProjectSlug(""), false); + for (const reserved of RESERVED_PROJECT_SLUGS) { + assert.equal(isValidProjectSlug(reserved), false); + } + }); + + test("withCollisionSuffix keeps the base and appends 4 hex chars", () => { + const suffixed = withCollisionSuffix("my-app"); + assert.match(suffixed, /^my-app-[0-9a-f]{4}$/); + }); +}); + +describe("ProjectStore", () => { + test("persists atomically and reloads from disk", () => { + const ws = makeWorkspace(); + const filePath = join(ws.dir, "projects.json"); + try { + const store = ProjectStore.load(filePath); + store.add({ id: "a", name: "A", createdAt: "2026-01-01T00:00:00.000Z" }); + store.add({ id: "b", name: "B", createdAt: "2026-01-02T00:00:00.000Z" }); + + const reopened = ProjectStore.load(filePath); + assert.equal(reopened.has("a"), true); + assert.equal(reopened.get("b")?.name, "B"); + // Newest first. + assert.deepEqual( + reopened.list().map((r) => r.id), + ["b", "a"], + ); + } finally { + ws.cleanup(); + } + }); + + test("rejects a duplicate id and removes cleanly", () => { + const ws = makeWorkspace(); + const filePath = join(ws.dir, "projects.json"); + try { + const store = ProjectStore.load(filePath); + store.add({ id: "a", name: "A", createdAt: "2026-01-01T00:00:00.000Z" }); + assert.throws(() => store.add({ id: "a", name: "dup", createdAt: "2026-01-03T00:00:00.000Z" })); + store.remove("a"); + assert.equal(ProjectStore.load(filePath).has("a"), false); + } finally { + ws.cleanup(); + } + }); + + test("a corrupt registry file is a loud failure", () => { + const ws = makeWorkspace(); + const filePath = join(ws.dir, "projects.json"); + try { + writeFileSync(filePath, "{not json"); + assert.throws(() => ProjectStore.load(filePath), /corrupt projects registry/); + } finally { + ws.cleanup(); + } + }); +}); + +describe("ProjectRegistry lifecycle", () => { + test("createProject assigns slug id, makes the dir, and persists", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ + workspaceDir: ws.dir, + logger: silentLogger, + }); + const project = registry.createProject({ name: "My Cool App" }); + + assert.equal(project.id, "my-cool-app"); + assert.equal(project.name, "My Cool App"); + assert.equal(project.projectDir, join(ws.dir, "my-cool-app")); + assert.ok(existsSync(project.projectDir), "project dir created"); + assert.ok(existsSync(join(ws.dir, ".pi-global", "projects.json")), "registry persisted under .pi-global"); + } finally { + ws.cleanup(); + } + }); + + test("is idempotent on name and rehydrates on a fresh registry", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ + workspaceDir: ws.dir, + logger: silentLogger, + }); + const first = registry.createProject({ name: "my-app" }); + const again = registry.createProject({ name: "my-app" }); + assert.equal(again.id, first.id); + assert.equal(again.createdAt, first.createdAt); + assert.equal(registry.listProjects().length, 1); + + const reopened = await ProjectRegistry.create({ + workspaceDir: ws.dir, + logger: silentLogger, + }); + assert.equal(reopened.getProject("my-app")?.name, "my-app"); + assert.equal(reopened.listProjects().length, 1); + } finally { + ws.cleanup(); + } + }); + + test("different names that slugify the same coexist via a suffix", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ + workspaceDir: ws.dir, + logger: silentLogger, + }); + const first = registry.createProject({ name: "My App" }); // -> my-app + const second = registry.createProject({ name: "my-app" }); // collision + assert.equal(first.id, "my-app"); + assert.notEqual(second.id, first.id); + assert.match(second.id, /^my-app-[0-9a-f]{4}$/); + assert.equal(registry.listProjects().length, 2); + } finally { + ws.cleanup(); + } + }); + + test("rejects names that yield no valid slug", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ + workspaceDir: ws.dir, + logger: silentLogger, + }); + assert.throws(() => registry.createProject({ name: " " })); + assert.throws(() => registry.createProject({ name: "!!!" })); + } finally { + ws.cleanup(); + } + }); + + test("getRuntime returns null for unknown projects, a runtime once created", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ + workspaceDir: ws.dir, + logger: silentLogger, + }); + assert.equal(await registry.getRuntime("nope"), null); + + const project = registry.createProject({ name: "game" }); + const runtime = await registry.getRuntime(project.id); + assert.ok(runtime, "runtime built for a registered project"); + // Transcripts are centralised under .pi-global/sessions/{id}. + assert.ok( + existsSync(join(ws.dir, ".pi-global", "sessions", project.id)), + "sessions dir created under .pi-global", + ); + } finally { + ws.cleanup(); + } + }); + + test("removeProject deletes metadata, working dir, and transcripts", async () => { + const ws = makeWorkspace(); + try { + const registry = await ProjectRegistry.create({ + workspaceDir: ws.dir, + logger: silentLogger, + }); + const project = registry.createProject({ name: "ephemeral" }); + await registry.getRuntime(project.id); // materialise sessions dir + writeFileSync(join(project.projectDir, "marker.txt"), "x"); + + assert.equal(registry.removeProject(project.id), true); + assert.equal(registry.getProject(project.id), null); + assert.equal(existsSync(project.projectDir), false); + assert.equal(existsSync(join(ws.dir, ".pi-global", "sessions", project.id)), false); + // Removing an unknown project is a no-op false. + assert.equal(registry.removeProject("nope"), false); + + // Persisted registry reflects the removal. + const registryFile = readFileSync(join(ws.dir, ".pi-global", "projects.json"), "utf8"); + assert.equal(registryFile.includes("ephemeral"), false); + } finally { + ws.cleanup(); + } + }); +}); diff --git a/test/projectRuntimeServices.test.ts b/test/projectRuntimeServices.test.ts new file mode 100644 index 0000000..b1991db --- /dev/null +++ b/test/projectRuntimeServices.test.ts @@ -0,0 +1,348 @@ +/** + * Unit tests for the AgentSessionServices integration in ProjectRuntime. + * + * These tests assert the contract added by the + * docs/architecture/use-agent-session-services.md refactor: + * + * - The services bundle is shared across every session in a project + * (proves we're not recreating ResourceLoader / SettingsManager + * per session — the whole point of the refactor). + * - reload() invokes resourceLoader.reload() and is idempotent. + * - diagnostics() exposes the live array from services (identity, not + * a snapshot copy). + * - Extension factories run exactly once at project startup, even when + * N sessions are created — guards against the regression where a + * factory was re-invoked for every session. + * - An **explicitly configured** missing agentsFile is a fatal startup + * error (loud misconfig). The **convention default** missing + * `/.pi/AGENTS.md` is a silent skip — the runtime + * starts with no pinned prompt. + */ + +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, test } from "node:test"; +import { AuthStorage, type ExtensionFactory, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { AgentCredentialsService } from "../src/credentials/credentialsService.js"; +import { ProjectRuntime } from "../src/runtime/projectRuntime.js"; + +const silentLogger = { log: () => {}, error: () => {} } as const; + +function makeProject(): { dir: string; cleanup: () => void } { + const dir = mkdtempSync(resolve(tmpdir(), "project-runtime-services-test-")); + mkdirSync(resolve(dir, ".pi"), { recursive: true }); + writeFileSync(resolve(dir, ".pi/AGENTS.md"), "# test agents file\n"); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +/** + * Variant that does **not** create `.pi/AGENTS.md` — used to verify + * convention-default behaviour (silent skip when the file is absent). + */ +function makeProjectWithoutAgentsFile(): { dir: string; cleanup: () => void } { + const dir = mkdtempSync(resolve(tmpdir(), "project-runtime-services-test-noprompt-")); + mkdirSync(resolve(dir, ".pi"), { recursive: true }); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +/** + * Build the minimal credentials trio every ProjectRuntime needs in + * tests. Using a separate agentDir per call keeps tests independent + * (auth.json/models.json are written to disk eagerly). + */ +function makeCredentials(agentDir: string) { + mkdirSync(agentDir, { recursive: true }); + const authStorage = AuthStorage.create(resolve(agentDir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agentDir, "models.json")); + const credentials = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agentDir, "models.json"), + logger: silentLogger, + }); + return { authStorage, modelRegistry, credentials }; +} + +describe("ProjectRuntime — AgentSessionServices integration", () => { + test("services.resourceLoader is the same instance across two sessions", async () => { + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + const runtime = await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }); + + const a = await runtime.createNewSession(); + const b = await runtime.createNewSession(); + + // Identity check — proves both sessions are wired to the same + // services bundle and we're not paying for per-session + // ResourceLoader construction. + assert.equal(runtime.services.resourceLoader, runtime.services.resourceLoader); + assert.notEqual(a.sessionId, b.sessionId); + } finally { + project.cleanup(); + } + }); + + test("services.settingsManager is shared, not recreated per session", async () => { + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + const runtime = await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }); + await runtime.createNewSession(); + const captured = runtime.services.settingsManager; + await runtime.createNewSession(); + assert.equal(runtime.services.settingsManager, captured); + } finally { + project.cleanup(); + } + }); + + test("deleteSession() removes a session and is idempotent / 404-aware", async () => { + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + const runtime = await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }); + + const session = await runtime.createNewSession(); + assert.ok((await runtime.listSessions()).some((row) => row.id === session.sessionId)); + + // Existing (in-memory, not yet flushed) session is removed. + assert.equal(await runtime.deleteSession(session.sessionId), true); + assert.equal( + (await runtime.listSessions()).some((row) => row.id === session.sessionId), + false, + ); + assert.equal(await runtime.getSession(session.sessionId), null); + + // Deleting again (or an unknown id) reports "not found" so routes 404. + assert.equal(await runtime.deleteSession(session.sessionId), false); + assert.equal(await runtime.deleteSession("does-not-exist"), false); + } finally { + project.cleanup(); + } + }); + + test("diagnostics() returns the live services array (identity, not copy)", async () => { + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + const runtime = await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }); + assert.equal(runtime.diagnostics(), runtime.services.diagnostics); + } finally { + project.cleanup(); + } + }); + + test("reload() refreshes resourceLoader and is idempotent", async () => { + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + const runtime = await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }); + + // Spy on the loader's reload() to count invocations. Restore + // afterwards so we don't pollute later tests sharing the same + // loader instance (we don't, but defense in depth). + const originalReload = runtime.services.resourceLoader.reload.bind(runtime.services.resourceLoader); + let calls = 0; + runtime.services.resourceLoader.reload = async () => { + calls += 1; + return originalReload(); + }; + + await runtime.reload(); + assert.equal(calls, 1); + await runtime.reload(); + assert.equal(calls, 2); + } finally { + project.cleanup(); + } + }); + + test("extension factories run exactly once at project startup, not per session", async () => { + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + let factoryCallCount = 0; + // Minimal extension factory: returns a no-op extension. We + // only care about how many times the factory itself is + // invoked — that's what was previously O(N) in sessions. + const factory: ExtensionFactory = () => { + factoryCallCount += 1; + return { name: "test-counter-ext" }; + }; + + const runtime = await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + extensionFactories: [factory], + noExtensions: true, // suppress disk discovery; only our factory should run + noSkills: true, + noPromptTemplates: true, + noThemes: true, + logger: silentLogger, + }); + + await runtime.createNewSession(); + await runtime.createNewSession(); + await runtime.createNewSession(); + + assert.equal( + factoryCallCount, + 1, + `expected extension factory to run once at project startup, ran ${factoryCallCount}x`, + ); + } finally { + project.cleanup(); + } + }); + + test("ProjectRuntime.create() rejects when an explicitly configured agentsFile is missing", async () => { + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + await assert.rejects( + ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + agentsFile: ".pi/does-not-exist.md", + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }), + /does-not-exist|ENOENT/, + ); + } finally { + project.cleanup(); + } + }); + + test("ProjectRuntime.create() silently skips a missing convention-default AGENTS.md", async () => { + // Convention-default semantics: when `agentsFile` is unset, the + // runtime falls back to `/.pi/AGENTS.md` and treats + // "file not present" as the natural no-prompt signal. This is + // what replaces the old `defaultAgentsFile: false` kill switch — + // multi-mode default runtimes pointed at a host root with no + // AGENTS.md just start up fine. + const project = makeProjectWithoutAgentsFile(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + const runtime = await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }); + assert.ok(runtime, "runtime should construct without an AGENTS.md"); + const promptDiagnostics = runtime + .diagnostics() + .filter((diagnostic) => /agentsFile|AGENTS\.md/i.test(diagnostic.message)); + assert.deepEqual(promptDiagnostics, [], "no prompt-load diagnostics expected"); + } finally { + project.cleanup(); + } + }); + + test("ProjectRuntime.create() writes /.pi/.gitignore excluding sessions/", async () => { + // Auto-gitignore is the safety net that keeps session transcripts + // out of git. Without it, a developer running `git add .pi/` to + // commit AGENTS.md / skills / extensions would also stage every + // chat history JSONL file the runtime has written. Verify the + // gitignore is created on first runtime construction. + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + try { + await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }); + const gitignorePath = resolve(project.dir, ".pi/.gitignore"); + assert.ok(existsSync(gitignorePath), `expected ${gitignorePath} to be created on first runtime construction`); + const contents = readFileSync(gitignorePath, "utf8"); + assert.match(contents, /^sessions\/$/m, "gitignore should contain a 'sessions/' rule"); + } finally { + project.cleanup(); + } + }); + + test("ProjectRuntime.create() leaves an existing .pi/.gitignore untouched (idempotent)", async () => { + // Strict idempotency: surprise mutation of files in someone's + // workspace is worse than missing a default. If the operator + // already has a custom .gitignore we don't overwrite it — they + // can take responsibility for adding 'sessions/' themselves. + const project = makeProject(); + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + const customContents = "# my own gitignore\n*.log\n"; + writeFileSync(resolve(project.dir, ".pi/.gitignore"), customContents); + try { + await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: silentLogger, + }); + const contents = readFileSync(resolve(project.dir, ".pi/.gitignore"), "utf8"); + assert.equal(contents, customContents, "existing .gitignore must not be modified"); + } finally { + project.cleanup(); + } + }); +}); diff --git a/test/projectSession.test.ts b/test/projectSession.test.ts new file mode 100644 index 0000000..cd95402 --- /dev/null +++ b/test/projectSession.test.ts @@ -0,0 +1,500 @@ +/** + * Unit tests for ProjectSession. + * + * Rather than spinning up a real AgentSession (which requires resource + * loading, extension binding, and a session manager), we drive ProjectSession + * with a hand-rolled fake AgentSession that implements only the surface + * ProjectSession actually touches: + * + * - subscribe / dispatchEvent + * - bindExtensions (resolves with the bindings object so we can introspect it) + * - isStreaming / state.messages / model / thinkingLevel / + * getAvailableThinkingLevels / supportsThinking + * - prompt / abort / setModel / setThinkingLevel / reload + * - agent.waitForIdle (used by commandActions) + * + * The fake captures bindings passed into bindExtensions so tests can drive + * the ExtensionUIContext directly (e.g. invoke ui.confirm() and observe the + * SSE publish + pendingExtensionUiRequests bookkeeping). + * + * SSE publishing is a process-wide singleton; tests `subscribe` to a per-test + * sessionId and assert the events that arrived. Each test uses a unique + * sessionId to avoid cross-test interference. + */ + +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import type { AgentSession, ExtensionBindings } from "@earendil-works/pi-coding-agent"; +import type { AgentCredentialsService, AgentModelRow } from "../src/credentials/credentialsService.js"; +import { subscribe } from "../src/http/sseBroker.js"; +import { ProjectSession } from "../src/runtime/projectSession.js"; +import type { ThinkingLevel } from "../src/shared/thinking.js"; + +type FakeListener = (event: unknown) => void; + +interface FakeAgentSession { + sessionId: string; + model: { provider: string; id: string } | undefined; + thinkingLevel: ThinkingLevel; + isStreaming: boolean; + state: { messages: Array<{ role: string; content: Array<{ type: string; text?: string }> }> }; + subscribe: AgentSession["subscribe"]; + bindExtensions: AgentSession["bindExtensions"]; + prompt: AgentSession["prompt"]; + abort: AgentSession["abort"]; + setModel: AgentSession["setModel"]; + setThinkingLevel: AgentSession["setThinkingLevel"]; + getAvailableThinkingLevels: AgentSession["getAvailableThinkingLevels"]; + supportsThinking: AgentSession["supportsThinking"]; + reload: AgentSession["reload"]; + agent: AgentSession["agent"]; + // test-only helpers + dispatch(event: unknown): void; + bindings(): ExtensionBindings | undefined; + bindExtensionsResolveAfter?: Promise; +} + +interface MakeFakeOptions { + sessionId?: string; + isStreaming?: boolean; + model?: { provider: string; id: string }; + thinkingLevel?: ThinkingLevel; + availableThinkingLevels?: ThinkingLevel[]; + supportsThinking?: boolean; + bindExtensionsBehavior?: "resolve" | "reject"; + messages?: Array<{ role: string; content: Array<{ type: string; text?: string }> }>; +} + +interface FakeRecording { + prompts: Array<{ text: string; options?: unknown }>; + aborts: number; + setModelCalls: Array<{ provider: string; id: string }>; + setThinkingLevelCalls: ThinkingLevel[]; + reloads: number; +} + +function makeFakeSession(opts: MakeFakeOptions = {}): { + session: AgentSession; + rec: FakeRecording; + dispatch: (event: unknown) => void; + bindings: () => ExtensionBindings | undefined; +} { + const listeners = new Set(); + const rec: FakeRecording = { + prompts: [], + aborts: 0, + setModelCalls: [], + setThinkingLevelCalls: [], + reloads: 0, + }; + let capturedBindings: ExtensionBindings | undefined; + const fake: FakeAgentSession = { + sessionId: opts.sessionId ?? "test-session", + model: opts.model, + thinkingLevel: opts.thinkingLevel ?? "off", + isStreaming: opts.isStreaming ?? false, + state: { messages: opts.messages ?? [] }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async bindExtensions(bindings) { + capturedBindings = bindings; + if (opts.bindExtensionsBehavior === "reject") { + throw new Error("bindExtensions failed"); + } + }, + async prompt(text, options) { + rec.prompts.push({ text, options }); + }, + async abort() { + rec.aborts += 1; + }, + async setModel(model) { + rec.setModelCalls.push({ provider: model.provider, id: model.id }); + fake.model = { provider: model.provider, id: model.id }; + }, + setThinkingLevel(level) { + rec.setThinkingLevelCalls.push(level as ThinkingLevel); + fake.thinkingLevel = level as ThinkingLevel; + }, + getAvailableThinkingLevels() { + return (opts.availableThinkingLevels ?? ["off"]) as ThinkingLevel[]; + }, + supportsThinking() { + return opts.supportsThinking ?? false; + }, + async reload() { + rec.reloads += 1; + }, + agent: { + async waitForIdle() {}, + } as unknown as AgentSession["agent"], + dispatch(event) { + for (const l of listeners) l(event); + }, + bindings: () => capturedBindings, + }; + return { + session: fake as unknown as AgentSession, + rec, + dispatch: fake.dispatch, + bindings: fake.bindings, + }; +} + +function makeFakeDeps(): { + credentials: AgentCredentialsService; + modelRegistry: { find: (provider: string, id: string) => unknown }; + logger: { log: () => void; error: () => void }; + models: Map; + defaultThinking: Map; +} { + const models = new Map(); + const defaultThinking = new Map(); + const credentials = { + modelRow: (model: { provider: string; id: string }): AgentModelRow => + ({ provider: model.provider, id: model.id }) as AgentModelRow, + defaultThinkingForModel: (model: { provider: string; id: string }) => + defaultThinking.get(`${model.provider}/${model.id}`), + } as unknown as AgentCredentialsService; + const modelRegistry = { + find: (provider: string, id: string) => models.get(`${provider}/${id}`), + }; + const logger = { log: () => {}, error: () => {} }; + return { credentials, modelRegistry, logger, models, defaultThinking }; +} + +/** + * Subscribe to the SSE broker for a sessionId and return both the captured + * events and a cleanup. Tests should call cleanup at the end so subscriptions + * don't leak across tests (the broker is process-wide). + */ +function captureSseEvents(sessionId: string): { + events: unknown[]; + stop: () => void; +} { + const events: unknown[] = []; + const unsubscribe = subscribe(sessionId, (event) => { + events.push(event); + }); + return { events, stop: unsubscribe }; +} + +describe("ProjectSession — event subscription", () => { + test("forwards AgentSession events to SSE broker keyed by sessionId", async () => { + const sessionId = "ev-fwd-1"; + const { session, dispatch } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const capture = captureSseEvents(sessionId); + try { + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + dispatch({ type: "message_start", id: "m1" }); + dispatch({ type: "message_end", id: "m1" }); + assert.deepEqual(capture.events, [ + { type: "message_start", id: "m1" }, + { type: "message_end", id: "m1" }, + ]); + } finally { + capture.stop(); + } + }); + + test("logs a run error when a run ends with stopReason 'error'", async () => { + const sessionId = "ev-run-error"; + const { session, dispatch } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const errors: string[] = []; + deps.logger.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + + dispatch({ + type: "agent_end", + willRetry: false, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [], stopReason: "error", errorMessage: "401 bad key" }, + ], + }); + + assert.equal(errors.length, 1); + assert.match(errors[0]!, /run error/); + assert.match(errors[0]!, /401 bad key/); + }); + + test("does not log a run error while an auto-retry is pending (willRetry)", async () => { + const sessionId = "ev-run-error-retry"; + const { session, dispatch } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const errors: string[] = []; + deps.logger.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + + dispatch({ + type: "agent_end", + willRetry: true, + messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: "429 rate limited" }], + }); + + assert.equal(errors.length, 0); + }); + + test("publishes extension_error when bindExtensions rejects", async () => { + const sessionId = "ev-bind-fail"; + const { session } = makeFakeSession({ sessionId, bindExtensionsBehavior: "reject" }); + const deps = makeFakeDeps(); + const capture = captureSseEvents(sessionId); + try { + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + const err = capture.events.find( + (e): e is { type: "extension_error"; extensionPath: string } => + typeof e === "object" && e !== null && (e as { type?: unknown }).type === "extension_error", + ); + assert.ok(err, "expected an extension_error event"); + assert.equal(err.extensionPath, ""); + } finally { + capture.stop(); + } + }); +}); + +describe("ProjectSession — sendPrompt", () => { + test("awaits extensionsReady before delegating to session.prompt", async () => { + const sessionId = "send-1"; + const { session, rec } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.sendPrompt("hello"); + assert.equal(rec.prompts.length, 1); + assert.equal(rec.prompts[0].text, "hello"); + assert.equal(rec.prompts[0].options, undefined); + }); + + test("uses streamingBehavior: 'steer' when session is already streaming", async () => { + const sessionId = "send-streaming"; + const { session, rec } = makeFakeSession({ sessionId, isStreaming: true }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.sendPrompt("interrupt"); + assert.equal(rec.prompts.length, 1); + assert.deepEqual(rec.prompts[0].options, { streamingBehavior: "steer" }); + }); +}); + +describe("ProjectSession — abort", () => { + test("no-op when not streaming", async () => { + const { session, rec } = makeFakeSession({ sessionId: "abort-idle", isStreaming: false }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.abort(); + assert.equal(rec.aborts, 0); + }); + + test("calls session.abort when streaming", async () => { + const { session, rec } = makeFakeSession({ sessionId: "abort-running", isStreaming: true }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.abort(); + assert.equal(rec.aborts, 1); + }); +}); + +describe("ProjectSession — model settings", () => { + test("setModel rejects while streaming", async () => { + const { session } = makeFakeSession({ sessionId: "model-streaming", isStreaming: true }); + const deps = makeFakeDeps(); + deps.models.set("anthropic/claude", { provider: "anthropic", id: "claude" }); + const ps = new ProjectSession(session, deps); + await assert.rejects(() => ps.setModel("anthropic", "claude"), /while the agent is running/); + }); + + test("setModel rejects unknown model", async () => { + const { session } = makeFakeSession({ sessionId: "model-unknown" }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await assert.rejects(() => ps.setModel("bogus", "missing"), /not found/); + }); + + test("setModel applies thinking-level default when current level isn't supported by the new model", async () => { + const { session, rec } = makeFakeSession({ + sessionId: "model-thinking-default", + thinkingLevel: "high", + availableThinkingLevels: ["off", "low", "medium"], + }); + const deps = makeFakeDeps(); + const newModel = { provider: "anthropic", id: "haiku" }; + deps.models.set("anthropic/haiku", newModel); + deps.defaultThinking.set("anthropic/haiku", "medium"); + const ps = new ProjectSession(session, deps); + await ps.setModel("anthropic", "haiku"); + assert.equal(rec.setModelCalls.length, 1); + assert.deepEqual(rec.setModelCalls[0], { provider: "anthropic", id: "haiku" }); + assert.deepEqual(rec.setThinkingLevelCalls, ["medium"]); + }); + + test("setThinkingLevel rejects while streaming", () => { + const { session } = makeFakeSession({ sessionId: "thinking-streaming", isStreaming: true }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + assert.throws(() => ps.setThinkingLevel("high"), /while the agent is running/); + }); + + test("updateModelSettings applies model and thinking changes atomically", async () => { + const { session, rec } = makeFakeSession({ + sessionId: "update-atomic", + availableThinkingLevels: ["off", "low", "medium", "high"], + }); + const deps = makeFakeDeps(); + deps.models.set("anthropic/sonnet", { provider: "anthropic", id: "sonnet" }); + const ps = new ProjectSession(session, deps); + await ps.updateModelSettings({ provider: "anthropic", modelId: "sonnet", thinkingLevel: "high" }); + assert.equal(rec.setModelCalls.length, 1); + assert.deepEqual(rec.setThinkingLevelCalls, ["high"]); + }); +}); + +describe("ProjectSession — extension UI dialog flow", () => { + test("select returns the value when client responds, removes pending entry", async () => { + const sessionId = "dialog-select"; + const { session, bindings } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + + const ui = bindings()?.uiContext; + assert.ok(ui, "uiContext was bound"); + const promise = ui!.select("Pick", ["A", "B"]); + const pending = ps.pendingExtensionUiRequests(); + assert.equal(pending.length, 1); + assert.equal(pending[0].method, "select"); + const requestId = pending[0].id; + const accepted = ps.resolveExtensionUiRequest(requestId, { value: "A" }); + assert.equal(accepted, true); + const result = await promise; + assert.equal(result, "A"); + assert.equal(ps.pendingExtensionUiRequests().length, 0); + }); + + test("confirm returns false when client cancels", async () => { + const sessionId = "dialog-cancel"; + const { session, bindings } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + + const ui = bindings()?.uiContext; + const promise = ui!.confirm("Are you sure?", "yes/no"); + const requestId = ps.pendingExtensionUiRequests()[0].id; + ps.resolveExtensionUiRequest(requestId, { cancelled: true }); + const result = await promise; + assert.equal(result, false); + }); + + test("input returns fallback when timeout fires", async () => { + const sessionId = "dialog-timeout"; + const { session, bindings } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + + const ui = bindings()?.uiContext; + const result = await ui!.input("Name?", undefined, { timeout: 5 }); + assert.equal(result, undefined); + assert.equal(ps.pendingExtensionUiRequests().length, 0); + }); + + test("input returns fallback when AbortSignal aborts", async () => { + const sessionId = "dialog-abort"; + const { session, bindings } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + + const controller = new AbortController(); + const ui = bindings()?.uiContext; + const promise = ui!.input("Name?", undefined, { signal: controller.signal }); + controller.abort(); + const result = await promise; + assert.equal(result, undefined); + assert.equal(ps.pendingExtensionUiRequests().length, 0); + }); + + test("resolveExtensionUiRequest returns false for unknown request id", async () => { + const { session } = makeFakeSession({ sessionId: "unknown-req" }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + assert.equal(ps.resolveExtensionUiRequest("nonexistent", { value: "x" }), false); + }); + + test("two ProjectSessions don't cross-pollinate pending UI requests", async () => { + // Regression: with separate pendingExtensionUi maps per session, + // resolving session A's request must not affect session B's. + const sessionA = "iso-A"; + const sessionB = "iso-B"; + const fakeA = makeFakeSession({ sessionId: sessionA }); + const fakeB = makeFakeSession({ sessionId: sessionB }); + const deps = makeFakeDeps(); + const psA = new ProjectSession(fakeA.session, deps); + const psB = new ProjectSession(fakeB.session, deps); + await psA.extensionsReady; + await psB.extensionsReady; + + const promiseA = fakeA.bindings()!.uiContext!.confirm("A?", "ok"); + const promiseB = fakeB.bindings()!.uiContext!.confirm("B?", "ok"); + + const reqA = psA.pendingExtensionUiRequests()[0].id; + const reqB = psB.pendingExtensionUiRequests()[0].id; + + // Cross-resolve attempts should fail (session A doesn't know req B's id) + assert.equal(psA.resolveExtensionUiRequest(reqB, { confirmed: true }), false); + assert.equal(psB.resolveExtensionUiRequest(reqA, { confirmed: true }), false); + + // Both sessions still have their pending requests + assert.equal(psA.pendingExtensionUiRequests().length, 1); + assert.equal(psB.pendingExtensionUiRequests().length, 1); + + // Resolve correctly + assert.equal(psA.resolveExtensionUiRequest(reqA, { confirmed: true }), true); + assert.equal(psB.resolveExtensionUiRequest(reqB, { confirmed: false }), true); + assert.equal(await promiseA, true); + assert.equal(await promiseB, false); + }); +}); + +describe("ProjectSession — dispose", () => { + test("cancels pending dialogs and unsubscribes", async () => { + const sessionId = "dispose-1"; + const { session, bindings } = makeFakeSession({ sessionId }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + + const ui = bindings()?.uiContext; + const promise = ui!.confirm("?", "?"); + assert.equal(ps.pendingExtensionUiRequests().length, 1); + + await ps.dispose(); + assert.equal(ps.pendingExtensionUiRequests().length, 0); + const result = await promise; + assert.equal(result, false, "confirm fallback on cancellation is false"); + }); + + test("dispose is idempotent", async () => { + const { session } = makeFakeSession({ sessionId: "dispose-idempotent" }); + const deps = makeFakeDeps(); + const ps = new ProjectSession(session, deps); + await ps.extensionsReady; + await ps.dispose(); + await ps.dispose(); + // no error thrown + }); +}); diff --git a/test/server.test.ts b/test/server.test.ts index d4e130e..948ab99 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -3,7 +3,7 @@ * * Spins up a real `OpenAPIHono` app on a random local port (per describe * block, so we can independently test the auth-on / auth-off - * configurations) and drives it with `fetch`. The `AgentRuntime` is real + * configurations) and drives it with `fetch`. The `ProjectRuntime` is real * — it reads `.pi/AGENTS.md` from a temp project dir we set up in * beforeAll — but no LLM call is ever made, so tests don't need an * `ANTHROPIC_API_KEY` and don't burn tokens. @@ -24,15 +24,21 @@ */ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { type AddressInfo, createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; -import { type AddressInfo, createServer, type Server } from "node:net"; import { after, before, describe, test } from "node:test"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { serve } from "@hono/node-server"; import { OpenAPIHono } from "@hono/zod-openapi"; -import { AgentRuntime } from "../src/runtime.js"; -import { createSessionsApp } from "../src/routes.js"; -import { publish } from "../src/sseBroker.js"; +import { AgentCredentialsService } from "../src/credentials/credentialsService.js"; +import { createCredentialsApp } from "../src/http/credentialsRoutes.js"; +import { createProjectsApp } from "../src/http/projectsRoutes.js"; +import { createSessionsApp } from "../src/http/sessionsRoutes.js"; +import { publish } from "../src/http/sseBroker.js"; +import { litellmRuntimeConfig, resetLiteLlmConfigForTests, resolveLiteLlmConfig } from "../src/providers/litellm.js"; +import { ProjectRegistry, type ProjectRegistryConfig } from "../src/runtime/projectRegistry.js"; +import { ProjectRuntime } from "../src/runtime/projectRuntime.js"; /** * Pick a free TCP port by binding to 0, reading the assigned port, and @@ -50,36 +56,46 @@ async function pickPort(): Promise { } /** - * Build a self-contained projectDir under the OS tmp, with a stub - * .pi/AGENTS.md so the runtime's pinned-system-prompt path resolves. - * Returned cleanup fn removes the dir. + * Build a self-contained workspace dir under the OS tmp. In the new model a + * project is created inside the workspace via `registry.createProject`; this + * just hands back an empty WORKSPACE_DIR root. */ function makeProject(): { dir: string; cleanup: () => void } { const dir = mkdtempSync(resolve(tmpdir(), "agent-server-test-")); - mkdirSync(resolve(dir, ".pi"), { recursive: true }); - mkdirSync(resolve(dir, "data/sessions"), { recursive: true }); - writeFileSync(resolve(dir, ".pi/AGENTS.md"), "# test agents file\n"); return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } /** - * Start a fully-wired agent-server (mirroring server.ts) on the given - * port, optionally with bearer auth. Returns the server handle and - * base URL. + * Build auth/model/credentials for test runtimes. + */ +function makeCredentials(agentDir: string): { + authStorage: AuthStorage; + modelRegistry: ModelRegistry; + credentials: AgentCredentialsService; +} { + const authStorage = AuthStorage.create(resolve(agentDir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agentDir, "models.json")); + const credentials = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agentDir, "models.json"), + logger: { log: () => {}, error: () => {} }, + }); + return { authStorage, modelRegistry, credentials }; +} + +/** + * Start a fully-wired agent-server (mirroring server.ts) on the given port, + * optionally with bearer auth. Creates a `default` project inside the workspace + * so session tests have a project to target. Returns the server handle, the + * base URL, and `sessionsBase` (the project-scoped prefix for session routes). */ async function startServer(opts: { projectDir: string; port: number; token?: string; -}): Promise<{ baseUrl: string; close: () => Promise }> { - const runtime = new AgentRuntime({ - projectDir: opts.projectDir, - sessionsDir: resolve(opts.projectDir, "data/sessions"), - agentsFile: ".pi/AGENTS.md", - // Silence the runtime's startup logs in test output. - logger: { log: () => {}, error: () => {} }, - }); - + runtimeConfig?: Partial; +}): Promise<{ baseUrl: string; sessionsBase: string; close: () => Promise }> { const root = new OpenAPIHono(); if (opts.token) { @@ -91,7 +107,34 @@ async function startServer(opts: { }); } - root.route("/v1", createSessionsApp(runtime)); + const registry = await ProjectRegistry.create({ + workspaceDir: opts.projectDir, + logger: { log: () => {}, error: () => {} }, + ...(opts.runtimeConfig ?? {}), + }); + + // Create the project the session tests operate on, and give it a stub + // .pi/AGENTS.md so the runtime's pinned-system-prompt path resolves. + const project = registry.createProject({ name: "default" }); + mkdirSync(resolve(project.projectDir, ".pi"), { recursive: true }); + writeFileSync(resolve(project.projectDir, ".pi/AGENTS.md"), "# test agents file\n"); + + root.route("/v1", createCredentialsApp(registry.credentials)); + root.route("/v1", createProjectsApp(registry)); + root.route( + "/v1/projects/:projectId", + createSessionsApp(async (c) => { + const runtime = await registry.getRuntime(c.req.param("projectId")); + if (!runtime) throw new Error("project not registered"); + return runtime; + }), + ); + root.onError((err, c) => { + if (err instanceof Error && err.message.includes("project not registered")) { + return c.json({ error: err.message }, 404); + } + return c.json({ error: "internal server error" }, 500); + }); root.doc("/openapi.json", { openapi: "3.1.0", info: { title: "Test Agent Server", version: "0.0.0" }, @@ -101,6 +144,7 @@ async function startServer(opts: { return { baseUrl: `http://127.0.0.1:${opts.port}`, + sessionsBase: `http://127.0.0.1:${opts.port}/v1/projects/${project.id}`, close: () => new Promise((res, rej) => { server.close((err) => (err ? rej(err) : res())); @@ -108,14 +152,117 @@ async function startServer(opts: { }; } +describe("agent-server: LiteLLM config", () => { + const envKeys = [ + "LITELLM_BASE_URL", + "LITELLM_API_KEY", + "LITELLM_MODELS", + "LITELLM_MODELS_JSON", + "LITELLM_DEFAULT_MODEL", + "LITELLM_DEFAULT_THINKING", + "LITELLM_COMPAT_JSON", + "LITELLM_API", + "LITELLM_REASONING", + "LITELLM_CONTEXT_WINDOW", + "LITELLM_MAX_TOKENS", + ]; + + after(() => { + resetLiteLlmConfigForTests(); + }); + + test("registers configured LiteLLM models with thinking defaults", async () => { + const previous = new Map(envKeys.map((key) => [key, process.env[key]])); + const project = makeProject(); + try { + process.env.LITELLM_BASE_URL = "http://litellm.test/v1"; + process.env.LITELLM_API_KEY = "test-key"; + process.env.LITELLM_DEFAULT_MODEL = "openai/gpt-5.5"; + process.env.LITELLM_DEFAULT_THINKING = "high"; + process.env.LITELLM_MODELS_JSON = JSON.stringify([{ id: "openai/gpt-5.5" }]); + resetLiteLlmConfigForTests(); + + const agentDir = resolve(project.dir, ".pi-agent"); + const authStorage = AuthStorage.create(resolve(agentDir, "auth.json")); + const modelRegistry = ModelRegistry.create(authStorage, resolve(agentDir, "models.json")); + const litellmConfig = litellmRuntimeConfig(); + litellmConfig.configureModelRegistry?.(modelRegistry); + const credentials = new AgentCredentialsService({ + authStorage, + modelRegistry, + modelsJsonPath: resolve(agentDir, "models.json"), + defaultModelProvider: litellmConfig.defaultModelProvider, + defaultModelId: litellmConfig.defaultModelId, + defaultThinkingLevel: litellmConfig.defaultThinkingLevel, + modelThinkingDefaults: litellmConfig.modelThinkingDefaults, + logger: { log: () => {}, error: () => {} }, + }); + await ProjectRuntime.create({ + ...litellmConfig, + configureModelRegistry: undefined, + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + logger: { log: () => {}, error: () => {} }, + }); + + const models = credentials.listModels().filter((model) => model.provider === "litellm"); + assert.equal(models.length, 1); + assert.equal(models[0]!.id, "openai/gpt-5.5"); + assert.equal(models[0]!.reasoning, true); + assert.equal(models[0]!.available, true); + assert.equal(models[0]!.defaultThinkingLevel, "xhigh"); + } finally { + for (const key of envKeys) { + const value = previous.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetLiteLlmConfigForTests(); + project.cleanup(); + } + }); + + test("applies preset compat when only a default LiteLLM model is configured", () => { + const previous = new Map(envKeys.map((key) => [key, process.env[key]])); + try { + process.env.LITELLM_BASE_URL = "http://litellm.test/v1"; + process.env.LITELLM_API_KEY = "test-key"; + process.env.LITELLM_DEFAULT_MODEL = "openai/gpt-5.5"; + delete process.env.LITELLM_MODELS; + delete process.env.LITELLM_MODELS_JSON; + delete process.env.LITELLM_COMPAT_JSON; + resetLiteLlmConfigForTests(); + + const config = resolveLiteLlmConfig(); + const compat = config?.defaultModel.compat as Record | undefined; + assert.equal(config?.defaultModel.api, "openai-responses"); + assert.equal(config?.defaultModel.reasoning, true); + assert.equal(compat?.thinkingFormat, "openai"); + assert.equal(compat?.supportsReasoningEffort, true); + assert.equal(compat?.maxTokensField, "max_output_tokens"); + } finally { + for (const key of envKeys) { + const value = previous.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetLiteLlmConfigForTests(); + } + }); +}); + describe("agent-server: REST surface", () => { const project = makeProject(); let baseUrl: string; + let sessionsBase: string; let close: () => Promise; before(async () => { const port = await pickPort(); - ({ baseUrl, close } = await startServer({ projectDir: project.dir, port })); + ({ baseUrl, sessionsBase, close } = await startServer({ projectDir: project.dir, port })); }); after(async () => { @@ -132,29 +279,409 @@ describe("agent-server: REST surface", () => { }); test("GET /v1/sessions starts empty", async () => { - const res = await fetch(`${baseUrl}/v1/sessions`); + const res = await fetch(`${sessionsBase}/sessions`); assert.equal(res.status, 200); const body = (await res.json()) as { sessions: unknown[] }; assert.deepEqual(body.sessions, []); }); test("POST /v1/sessions creates a session, GET /v1/sessions lists it", async () => { - const create = await fetch(`${baseUrl}/v1/sessions`, { method: "POST" }); + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); assert.equal(create.status, 200); const created = (await create.json()) as { id: string; createdAt: string }; assert.match(created.id, /[0-9a-f-]{16,}/); assert.match(created.createdAt, /^\d{4}-\d{2}-\d{2}T/); - const list = await fetch(`${baseUrl}/v1/sessions`); + const list = await fetch(`${sessionsBase}/sessions`); const { sessions } = (await list.json()) as { sessions: { id: string }[] }; assert.ok(sessions.some((s) => s.id === created.id)); }); + test("GET /v1/sessions/models lists public model metadata", async () => { + const res = await fetch(`${baseUrl}/v1/sessions/models`); + assert.equal(res.status, 200); + const body = (await res.json()) as { models: Array<{ provider: string; id: string; available: boolean }> }; + assert.ok(Array.isArray(body.models)); + assert.ok(body.models.length > 0); + assert.equal(typeof body.models[0]!.provider, "string"); + assert.equal(typeof body.models[0]!.id, "string"); + assert.equal(typeof body.models[0]!.available, "boolean"); + }); + + test("provider auth API stores status without exposing keys", async () => { + const before = await fetch(`${baseUrl}/v1/auth/providers`); + assert.equal(before.status, 200); + const initial = (await before.json()) as { + providers: Array<{ provider: string; configured: boolean; source?: string }>; + }; + assert.ok(initial.providers.some((p) => p.provider === "anthropic")); + + const put = await fetch(`${baseUrl}/v1/auth/providers/anthropic/api-key`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ key: "sk-ant-test-secret" }), + }); + assert.equal(put.status, 200); + assert.deepEqual((await put.json()) as { ok: boolean }, { ok: true }); + + const afterSet = await fetch(`${baseUrl}/v1/auth/providers`); + assert.equal(afterSet.status, 200); + const setText = await afterSet.text(); + assert.equal(setText.includes("sk-ant-test-secret"), false); + const setBody = JSON.parse(setText) as { + providers: Array<{ provider: string; configured: boolean; source?: string }>; + }; + const anthropic = setBody.providers.find((p) => p.provider === "anthropic"); + assert.equal(anthropic?.configured, true); + assert.equal(anthropic?.source, "stored"); + + const del = await fetch(`${baseUrl}/v1/auth/providers/anthropic`, { method: "DELETE" }); + assert.equal(del.status, 200); + assert.deepEqual((await del.json()) as { ok: boolean }, { ok: true }); + }); + + test("provider auth status treats runtime credentials as configured", async () => { + const project = makeProject(); + try { + const agentDir = resolve(project.dir, ".pi-agent"); + const { authStorage, modelRegistry, credentials } = makeCredentials(agentDir); + await ProjectRuntime.create({ + projectDir: project.dir, + agentDir, + credentials, + authStorage, + modelRegistry, + anthropicApiKey: "sk-ant-runtime-test", + logger: { log: () => {}, error: () => {} }, + }); + const anthropic = credentials.listAuthProviders().find((p) => p.provider === "anthropic"); + assert.equal(anthropic?.configured, true); + assert.equal(anthropic?.source, "runtime"); + } finally { + project.cleanup(); + } + }); + + test("subscription auth flow stores OAuth credentials without exposing tokens", async () => { + const project = makeProject(); + const port = await pickPort(); + const server = await startServer({ + projectDir: project.dir, + port, + runtimeConfig: { + configureModelRegistry: (modelRegistry) => { + modelRegistry.registerProvider("test-oauth", { + name: "Test OAuth", + baseUrl: "https://example.test/v1", + api: "openai-completions", + oauth: { + name: "Test Subscription", + login: async (callbacks: any) => { + callbacks.onAuth?.({ + url: "https://login.example.test/device", + instructions: "Paste the redirect URL.", + }); + const code = await callbacks.onManualCodeInput?.(); + if (code !== "ok") throw new Error("unexpected code"); + return { + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }; + }, + refreshToken: async (credentials: any) => credentials, + getApiKey: (credentials: any) => credentials.access, + }, + models: [ + { + id: "test-model", + name: "Test Model", + api: "openai-completions", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 1024, + }, + ], + }); + }, + }, + }); + try { + const start = await fetch(`${server.baseUrl}/v1/auth/providers/test-oauth/subscription/start`, { + method: "POST", + }); + assert.equal(start.status, 200); + const flow = (await start.json()) as { id: string; status: string; authUrl?: string }; + assert.equal(flow.status, "auth"); + assert.equal(flow.authUrl, "https://login.example.test/device"); + + const cont = await fetch(`${server.baseUrl}/v1/auth/subscription/${flow.id}/continue`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: "ok" }), + }); + assert.equal(cont.status, 200); + const completed = await cont.text(); + assert.equal(completed.includes("oauth-access-token"), false); + const completedState = JSON.parse(completed) as { status: string }; + assert.equal(completedState.status, "complete"); + + const providers = await fetch(`${server.baseUrl}/v1/auth/providers`); + const providerText = await providers.text(); + assert.equal(providerText.includes("oauth-access-token"), false); + const providerBody = JSON.parse(providerText) as { + providers: Array<{ provider: string; configured: boolean; credentialType?: string; source?: string }>; + }; + const provider = providerBody.providers.find((entry) => entry.provider === "test-oauth"); + assert.equal(provider?.configured, true); + assert.equal(provider?.credentialType, "oauth"); + assert.equal(provider?.source, "stored"); + } finally { + await server.close(); + project.cleanup(); + } + }); + + test("subscription auth start reuses an active provider flow", async () => { + const project = makeProject(); + const port = await pickPort(); + let loginCalls = 0; + const server = await startServer({ + projectDir: project.dir, + port, + runtimeConfig: { + configureModelRegistry: (modelRegistry) => { + modelRegistry.registerProvider("test-reuse-oauth", { + name: "Test Reuse OAuth", + baseUrl: "https://example.test/v1", + api: "openai-completions", + oauth: { + name: "Test Reuse Subscription", + login: async (callbacks: any) => { + loginCalls += 1; + callbacks.onAuth?.({ + url: "https://login.example.test/reuse", + instructions: "Complete login.", + }); + await callbacks.onManualCodeInput?.(); + return { + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }; + }, + refreshToken: async (credentials: any) => credentials, + getApiKey: (credentials: any) => credentials.access, + }, + models: [ + { + id: "test-reuse-model", + name: "Test Reuse Model", + api: "openai-completions", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 1024, + }, + ], + }); + }, + }, + }); + try { + const first = await fetch(`${server.baseUrl}/v1/auth/providers/test-reuse-oauth/subscription/start`, { + method: "POST", + }); + assert.equal(first.status, 200); + const firstFlow = (await first.json()) as { id: string; status: string; authUrl?: string }; + assert.equal(firstFlow.status, "auth"); + + const second = await fetch(`${server.baseUrl}/v1/auth/providers/test-reuse-oauth/subscription/start`, { + method: "POST", + }); + assert.equal(second.status, 200); + const secondFlow = (await second.json()) as { id: string; status: string; authUrl?: string }; + assert.equal(secondFlow.id, firstFlow.id); + assert.equal(secondFlow.status, "auth"); + assert.equal(secondFlow.authUrl, "https://login.example.test/reuse"); + assert.equal(loginCalls, 1); + + const cancel = await fetch(`${server.baseUrl}/v1/auth/subscription/${firstFlow.id}`, { + method: "DELETE", + }); + assert.equal(cancel.status, 200); + } finally { + await server.close(); + project.cleanup(); + } + }); + + test("subscription auth surfaces callback port conflicts as actionable errors", async () => { + const project = makeProject(); + const port = await pickPort(); + const server = await startServer({ + projectDir: project.dir, + port, + runtimeConfig: { + configureModelRegistry: (modelRegistry) => { + modelRegistry.registerProvider("test-port-oauth", { + name: "Test Port OAuth", + baseUrl: "https://example.test/v1", + api: "openai-completions", + oauth: { + name: "Test Port Subscription", + login: async () => { + throw new Error("listen EADDRINUSE: address already in use 127.0.0.1:53692"); + }, + refreshToken: async (credentials: any) => credentials, + getApiKey: (credentials: any) => credentials.access, + }, + models: [ + { + id: "test-port-model", + name: "Test Port Model", + api: "openai-completions", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 1024, + }, + ], + }); + }, + }, + }); + try { + const start = await fetch(`${server.baseUrl}/v1/auth/providers/test-port-oauth/subscription/start`, { + method: "POST", + }); + assert.equal(start.status, 200); + const flow = (await start.json()) as { status: string; error?: string }; + assert.equal(flow.status, "error"); + assert.equal( + flow.error, + "Test Port Subscription login callback is already running on its local port. Finish or cancel the existing login, then try again.", + ); + } finally { + await server.close(); + project.cleanup(); + } + }); + + test("custom provider API manages LiteLLM-style models without returning secrets", async () => { + const providerId = "litellm-ui-test"; + const save = await fetch(`${baseUrl}/v1/custom/providers`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + name: "LiteLLM UI Test", + baseUrl: "http://litellm.test/v1", + api: "openai-responses", + apiKey: "test-litellm-secret", + models: [ + { + id: "openai/gpt-5.5", + name: "GPT 5.5 via LiteLLM", + api: "openai-responses", + reasoning: true, + thinkingLevelMap: { + off: "none", + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + }, + input: ["text"], + contextWindow: 128000, + maxTokens: 16384, + compat: { supportsReasoningEffort: true, maxTokensField: "max_output_tokens" }, + }, + ], + }), + }); + assert.equal(save.status, 200); + const savedText = await save.text(); + assert.equal(savedText.includes("test-litellm-secret"), false); + const saved = JSON.parse(savedText) as { provider: string; apiKeyConfigured: boolean; modelCount: number }; + assert.equal(saved.provider, providerId); + assert.equal(saved.apiKeyConfigured, true); + assert.equal(saved.modelCount, 1); + + const list = await fetch(`${baseUrl}/v1/custom/providers`); + const listText = await list.text(); + assert.equal(listText.includes("test-litellm-secret"), false); + const listBody = JSON.parse(listText) as { providers: Array<{ provider: string; modelCount: number }> }; + assert.ok(listBody.providers.some((provider) => provider.provider === providerId && provider.modelCount === 1)); + + const models = await fetch(`${baseUrl}/v1/sessions/models`); + const modelBody = (await models.json()) as { + models: Array<{ provider: string; id: string; available: boolean; reasoning: boolean }>; + }; + const customModel = modelBody.models.find( + (model) => model.provider === providerId && model.id === "openai/gpt-5.5", + ); + assert.equal(customModel?.available, true); + assert.equal(customModel?.reasoning, true); + + const del = await fetch(`${baseUrl}/v1/custom/providers/${providerId}`, { method: "DELETE" }); + assert.equal(del.status, 200); + }); + + test("GET/PATCH /v1/sessions/{id}/settings exposes model and thinking controls", async () => { + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); + const { id } = (await create.json()) as { id: string }; + + const settings = await fetch(`${sessionsBase}/sessions/${id}/settings`); + assert.equal(settings.status, 200); + const body = (await settings.json()) as { + thinkingLevel: string; + availableThinkingLevels: string[]; + isStreaming: boolean; + }; + assert.equal(typeof body.thinkingLevel, "string"); + assert.ok(Array.isArray(body.availableThinkingLevels)); + assert.equal(body.isStreaming, false); + + const patch = await fetch(`${sessionsBase}/sessions/${id}/settings`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ thinkingLevel: "off" }), + }); + assert.equal(patch.status, 200); + const patched = (await patch.json()) as { thinkingLevel: string }; + assert.equal(patched.thinkingLevel, "off"); + }); + + test("PATCH /v1/sessions/{id}/settings rejects incomplete model pairs and empty bodies", async () => { + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); + const { id } = (await create.json()) as { id: string }; + + const missingModelId = await fetch(`${sessionsBase}/sessions/${id}/settings`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "anthropic" }), + }); + assert.equal(missingModelId.status, 400); + + const empty = await fetch(`${sessionsBase}/sessions/${id}/settings`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + assert.equal(empty.status, 400); + }); + test("GET /v1/sessions/{id} returns persisted history (empty for new session)", async () => { - const create = await fetch(`${baseUrl}/v1/sessions`, { method: "POST" }); + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); const { id } = (await create.json()) as { id: string }; - const res = await fetch(`${baseUrl}/v1/sessions/${id}`); + const res = await fetch(`${sessionsBase}/sessions/${id}`); assert.equal(res.status, 200); const body = (await res.json()) as { id: string; messages: unknown[] }; assert.equal(body.id, id); @@ -162,17 +689,17 @@ describe("agent-server: REST surface", () => { }); test("GET /v1/sessions/{unknown} → 404", async () => { - const res = await fetch(`${baseUrl}/v1/sessions/does-not-exist`); + const res = await fetch(`${sessionsBase}/sessions/does-not-exist`); assert.equal(res.status, 404); const body = (await res.json()) as { error: string }; assert.match(body.error, /not found/i); }); test("POST /v1/sessions/{id}/prompt with empty body → 400 from Zod", async () => { - const create = await fetch(`${baseUrl}/v1/sessions`, { method: "POST" }); + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); const { id } = (await create.json()) as { id: string }; - const res = await fetch(`${baseUrl}/v1/sessions/${id}/prompt`, { + const res = await fetch(`${sessionsBase}/sessions/${id}/prompt`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: "" }), @@ -181,11 +708,22 @@ describe("agent-server: REST surface", () => { assert.equal(res.status, 400); }); + test("POST /v1/sessions/{unknown}/prompt → 404", async () => { + const res = await fetch(`${sessionsBase}/sessions/does-not-exist/prompt`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "hello" }), + }); + assert.equal(res.status, 404); + const body = (await res.json()) as { error: string }; + assert.match(body.error, /not found/i); + }); + test("POST /v1/sessions/{id}/abort on idle session → 200 ok", async () => { - const create = await fetch(`${baseUrl}/v1/sessions`, { method: "POST" }); + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); const { id } = (await create.json()) as { id: string }; - const res = await fetch(`${baseUrl}/v1/sessions/${id}/abort`, { method: "POST" }); + const res = await fetch(`${sessionsBase}/sessions/${id}/abort`, { method: "POST" }); assert.equal(res.status, 200); const body = (await res.json()) as { ok: boolean }; assert.equal(body.ok, true); @@ -196,27 +734,204 @@ describe("agent-server: REST surface", () => { assert.equal(res.status, 200); const doc = (await res.json()) as { paths: Record }; for (const path of [ - "/v1/sessions", - "/v1/sessions/{id}", - "/v1/sessions/{id}/prompt", - "/v1/sessions/{id}/abort", - "/v1/sessions/{id}/events", + "/v1/auth/providers", + "/v1/auth/providers/{provider}/api-key", + "/v1/auth/providers/{provider}/subscription/start", + "/v1/auth/providers/{provider}", + "/v1/auth/subscription/{flowId}", + "/v1/auth/subscription/{flowId}/continue", + "/v1/custom/providers", + "/v1/custom/providers/{provider}", + "/v1/projects", + "/v1/projects/{id}", + "/v1/sessions/models", + "/v1/projects/{projectId}/sessions", + "/v1/projects/{projectId}/sessions/{id}", + "/v1/projects/{projectId}/sessions/{id}/settings", + "/v1/projects/{projectId}/sessions/{id}/prompt", + "/v1/projects/{projectId}/sessions/{id}/abort", + "/v1/projects/{projectId}/sessions/{id}/events", + "/v1/projects/{projectId}/sessions/{id}/extension-ui", + "/v1/projects/{projectId}/sessions/{id}/extension-ui/{requestId}/response", "/v1/healthz", ]) { assert.ok(doc.paths[path], `missing path ${path}`); } }); + + test("extension UI pending/response endpoints are wired", async () => { + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); + const { id } = (await create.json()) as { id: string }; + + const pending = await fetch(`${sessionsBase}/sessions/${id}/extension-ui`); + assert.equal(pending.status, 200); + assert.deepEqual((await pending.json()) as { requests: unknown[] }, { requests: [] }); + + const response = await fetch(`${sessionsBase}/sessions/${id}/extension-ui/not-real/response`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cancelled: true }), + }); + assert.equal(response.status, 404); + }); +}); + +describe("agent-server: project-scoped runtimes", () => { + /** Wire credentials + projects + session routes exactly like server.ts. */ + function mountServer(registry: ProjectRegistry, port: number) { + const root = new OpenAPIHono(); + root.route("/v1", createCredentialsApp(registry.credentials)); + root.route("/v1", createProjectsApp(registry)); + root.route( + "/v1/projects/:projectId", + createSessionsApp(async (c) => { + const runtime = await registry.getRuntime(c.req.param("projectId")); + if (!runtime) throw new Error("project not registered"); + return runtime; + }), + ); + root.onError((err, c) => { + if (err instanceof Error && err.message.includes("project not registered")) { + return c.json({ error: err.message }, 404); + } + return c.json({ error: "internal server error" }, 500); + }); + return serve({ fetch: root.fetch, hostname: "127.0.0.1", port }); + } + + test("credentials stay global; sessions require a registered project", async () => { + const workspace = makeProject(); + const port = await pickPort(); + const registry = await ProjectRegistry.create({ + workspaceDir: workspace.dir, + logger: { log: () => {}, error: () => {} }, + }); + const server = mountServer(registry, port); + const baseUrl = `http://127.0.0.1:${port}`; + + try { + const globalAuth = await fetch(`${baseUrl}/v1/auth/providers`); + assert.equal(globalAuth.status, 200); + + // Sessions for an unregistered project 404 — no implicit creation. + const unregistered = await fetch(`${baseUrl}/v1/projects/project-a/sessions`, { + method: "POST", + }); + assert.equal(unregistered.status, 404); + + // Create the project explicitly, then sessions resolve. + const created = await fetch(`${baseUrl}/v1/projects`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "project-a" }), + }); + assert.equal(created.status, 200); + const createdBody = (await created.json()) as { id: string }; + assert.equal(createdBody.id, "project-a"); + + const create = await fetch(`${baseUrl}/v1/projects/project-a/sessions`, { + method: "POST", + }); + assert.equal(create.status, 200); + } finally { + await new Promise((res, rej) => { + server.close((err) => (err ? rej(err) : res())); + }); + workspace.cleanup(); + } + }); + + test("POST /v1/projects is idempotent on name across restarts", async () => { + const workspace = makeProject(); + const port = await pickPort(); + const registry = await ProjectRegistry.create({ + workspaceDir: workspace.dir, + logger: { log: () => {}, error: () => {} }, + }); + const server = mountServer(registry, port); + const baseUrl = `http://127.0.0.1:${port}`; + + try { + const first = await fetch(`${baseUrl}/v1/projects`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "my-app" }), + }); + const firstBody = (await first.json()) as { id: string; createdAt: string }; + + const again = await fetch(`${baseUrl}/v1/projects`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "my-app" }), + }); + const againBody = (await again.json()) as { id: string; createdAt: string }; + + // Same id, same createdAt — the existing project is returned untouched. + assert.equal(againBody.id, firstBody.id); + assert.equal(againBody.createdAt, firstBody.createdAt); + + // A second registry over the same workspace rehydrates from projects.json. + const reopened = await ProjectRegistry.create({ + workspaceDir: workspace.dir, + logger: { log: () => {}, error: () => {} }, + }); + assert.ok(reopened.getProject("my-app")); + assert.equal(reopened.listProjects().length, 1); + } finally { + await new Promise((res, rej) => { + server.close((err) => (err ? rej(err) : res())); + }); + workspace.cleanup(); + } + }); + + test("project routes isolate sessions by project", async () => { + const workspace = makeProject(); + const port = await pickPort(); + const registry = await ProjectRegistry.create({ + workspaceDir: workspace.dir, + logger: { log: () => {}, error: () => {} }, + }); + registry.createProject({ name: "project-a" }); + registry.createProject({ name: "project-b" }); + const server = mountServer(registry, port); + const baseUrl = `http://127.0.0.1:${port}`; + + try { + const create = await fetch(`${baseUrl}/v1/projects/project-a/sessions`, { + method: "POST", + }); + assert.equal(create.status, 200); + const created = (await create.json()) as { id: string }; + + const listA = await fetch(`${baseUrl}/v1/projects/project-a/sessions`); + assert.equal(listA.status, 200); + const bodyA = (await listA.json()) as { sessions: { id: string }[] }; + assert.ok(bodyA.sessions.some((session) => session.id === created.id)); + + const listB = await fetch(`${baseUrl}/v1/projects/project-b/sessions`); + assert.equal(listB.status, 200); + const bodyB = (await listB.json()) as { sessions: { id: string }[] }; + assert.deepEqual(bodyB.sessions, []); + } finally { + await new Promise((res, rej) => { + server.close((err) => (err ? rej(err) : res())); + }); + workspace.cleanup(); + } + }); }); describe("agent-server: bearer auth seam", () => { const project = makeProject(); let baseUrl: string; + let sessionsBase: string; let close: () => Promise; const token = "test-token-deadbeef"; before(async () => { const port = await pickPort(); - ({ baseUrl, close } = await startServer({ + ({ baseUrl, sessionsBase, close } = await startServer({ projectDir: project.dir, port, token, @@ -229,19 +944,19 @@ describe("agent-server: bearer auth seam", () => { }); test("no token → 401", async () => { - const res = await fetch(`${baseUrl}/v1/sessions`); + const res = await fetch(`${sessionsBase}/sessions`); assert.equal(res.status, 401); }); test("wrong token → 401", async () => { - const res = await fetch(`${baseUrl}/v1/sessions`, { + const res = await fetch(`${sessionsBase}/sessions`, { headers: { authorization: "Bearer nope" }, }); assert.equal(res.status, 401); }); test("correct token → 200", async () => { - const res = await fetch(`${baseUrl}/v1/sessions`, { + const res = await fetch(`${sessionsBase}/sessions`, { headers: { authorization: `Bearer ${token}` }, }); assert.equal(res.status, 200); @@ -258,12 +973,12 @@ describe("agent-server: bearer auth seam", () => { describe("agent-server: SSE", () => { const project = makeProject(); - let baseUrl: string; + let sessionsBase: string; let close: () => Promise; before(async () => { const port = await pickPort(); - ({ baseUrl, close } = await startServer({ projectDir: project.dir, port })); + ({ sessionsBase, close } = await startServer({ projectDir: project.dir, port })); }); after(async () => { @@ -272,11 +987,11 @@ describe("agent-server: SSE", () => { }); test("connects, receives 'connected to ' frame, then a published event", async () => { - const create = await fetch(`${baseUrl}/v1/sessions`, { method: "POST" }); + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); const { id } = (await create.json()) as { id: string }; const ac = new AbortController(); - const res = await fetch(`${baseUrl}/v1/sessions/${id}/events`, { + const res = await fetch(`${sessionsBase}/sessions/${id}/events`, { signal: ac.signal, }); assert.equal(res.status, 200); @@ -312,17 +1027,17 @@ describe("agent-server: SSE", () => { }); test("connecting to unknown session id returns 404", async () => { - const res = await fetch(`${baseUrl}/v1/sessions/does-not-exist/events`); + const res = await fetch(`${sessionsBase}/sessions/does-not-exist/events`); assert.equal(res.status, 404); }); test("two subscribers on one channel both get a published event", async () => { - const create = await fetch(`${baseUrl}/v1/sessions`, { method: "POST" }); + const create = await fetch(`${sessionsBase}/sessions`, { method: "POST" }); const { id } = (await create.json()) as { id: string }; const open = async () => { const ac = new AbortController(); - const r = await fetch(`${baseUrl}/v1/sessions/${id}/events`, { + const r = await fetch(`${sessionsBase}/sessions/${id}/events`, { signal: ac.signal, }); const reader = r.body!.getReader(); @@ -338,10 +1053,7 @@ describe("agent-server: SSE", () => { publish(id, { type: "fanout-test" }); const dec = new TextDecoder(); - const readUntil = async ( - r: ReadableStreamDefaultReader, - needle: string, - ): Promise => { + const readUntil = async (r: ReadableStreamDefaultReader, needle: string): Promise => { let buf = ""; const deadline = Date.now() + 1000; while (!buf.includes(needle) && Date.now() < deadline) { diff --git a/test/thinking.test.ts b/test/thinking.test.ts new file mode 100644 index 0000000..fa96f83 --- /dev/null +++ b/test/thinking.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + clampThinkingLevelForModel, + supportedThinkingLevelsForModel, + THINKING_LEVELS, + type ThinkingLevel, +} from "../src/shared/thinking.js"; + +const reasoningModel = { + reasoning: true as const, + thinkingLevelMap: { off: "none", low: "low", medium: "medium", high: "high" } as Record< + string, + string | null | undefined + >, +}; + +const nonReasoningModel = { + reasoning: false as const, + thinkingLevelMap: undefined, +}; + +describe("thinking helpers", () => { + test("THINKING_LEVELS includes off and xhigh in canonical order", () => { + assert.deepEqual(THINKING_LEVELS, ["off", "minimal", "low", "medium", "high", "xhigh"] satisfies ThinkingLevel[]); + }); + + test("non-reasoning models support only off", () => { + assert.deepEqual(supportedThinkingLevelsForModel(nonReasoningModel), ["off"]); + }); + + test("supported levels exclude null entries and require explicit xhigh", () => { + const supported = supportedThinkingLevelsForModel(reasoningModel); + assert.ok(supported.includes("low")); + assert.ok(supported.includes("high")); + assert.ok(!supported.includes("xhigh"), "xhigh requires an explicit map entry"); + }); + + test("clamp picks the next-higher level when requested level is unsupported", () => { + const minimalNullModel = { + reasoning: true as const, + thinkingLevelMap: { off: "none", minimal: null, low: "low", medium: "medium", high: "high" } as Record< + string, + string | null | undefined + >, + }; + assert.equal(clampThinkingLevelForModel(minimalNullModel, "minimal"), "low"); + }); + + test("clamp falls back to the lowest supported level when requested is too high", () => { + const onlyOff = { reasoning: false as const, thinkingLevelMap: undefined }; + assert.equal(clampThinkingLevelForModel(onlyOff, "high"), "off"); + }); +}); diff --git a/tsconfig.gen.json b/tsconfig.gen.json new file mode 100644 index 0000000..9718327 --- /dev/null +++ b/tsconfig.gen.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": ".gen", + "plugins": [{ "transform": "typia/lib/transform" }] + }, + "include": ["scripts/genEventSchema.ts", "src/contract/wireEvents.ts", "src/shared/extensionUi.ts"] +}