mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-04 03:29:33 +00:00
Compare commits
4
Commits
a196c3a6b1
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9859e60d | ||
|
|
cb9ec7a869 | ||
|
|
19ae25040d | ||
|
|
836042b97c |
@@ -8,7 +8,7 @@ A Windows/macOS, repository-local distribution that connects the [Pi](https://gi
|
||||
|
||||
## 中文
|
||||
|
||||
Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆、配置和启动的项目。它保留前后端源码边界,通过根目录命令完成依赖安装、Pi 构建、插件安装、Profile 初始化和 Web 启动。
|
||||
Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆、配置和启动的项目。它保留前后端源码边界,采用前后端分离架构:独立的后端服务(`server/`,常驻 30142 端口)托管 Pi 运行时、Agent 会话与全部 API,Next.js 前端(`pi-web/`,30141 端口)只负责界面并通过代理转发 `/api` 请求——前端热重载或重启不会中断运行中的 Agent 会话。根目录命令完成依赖安装、Pi 构建、插件安装、Profile 初始化和双进程启动。
|
||||
|
||||
### 与两个源项目有什么不同
|
||||
|
||||
@@ -23,7 +23,7 @@ Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆
|
||||
| 浏览器自动化 | 需自行接入 | 无默认浏览器 MCP | Windows 默认使用 Edge;macOS 默认不启用,按需配置 |
|
||||
| 恢复与长期状态 | 会话树与基础状态 | 会话 UI | Rewind 检查点、项目内 Memory、自动重试 |
|
||||
| 多代理 | 核心能力可扩展 | 展示工具调用 | 配置了自动判断复杂度的 `pi-subagents` 策略 |
|
||||
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart |
|
||||
| 本项目修复 | 不适用 | 上游行为 | 修复已结束会话状态 404、隐藏 Next.js 开发指示器、增加 Windows/macOS restart、WebUI 流式输出自动跟踪与回到底部按钮 |
|
||||
|
||||
本仓库不是桌面安装包,也不是公网多用户服务。当前发布目标是:技术用户在 Windows 或 macOS 上克隆仓库、补充自己的模型凭据后,通过命令行启动一个隔离、可扩展的本地 Web Agent。
|
||||
|
||||
@@ -62,10 +62,13 @@ npm run dev
|
||||
|
||||
1. 初始化项目内 `data/` Profile;
|
||||
2. 安装并构建本地 Pi 源码;
|
||||
3. 安装 Pi Web 并连接本地 Pi 包;
|
||||
4. 安装、固定并加载检查默认插件;
|
||||
5. Windows 预缓存 Playwright MCP 包并确认系统 Edge 可用;macOS 跳过 Playwright 安装,浏览器自动化按需配置;
|
||||
6. 检查前后端版本和构建产物。
|
||||
3. 安装 Pi Web 前端依赖;
|
||||
4. 安装后端服务(`server/`)并链接本地 Pi 包;
|
||||
5. 安装、固定并加载检查默认插件;
|
||||
6. Windows 预缓存 Playwright MCP 包并确认系统 Edge 可用;macOS 跳过 Playwright 安装,浏览器自动化按需配置;
|
||||
7. 检查前后端版本和构建产物。
|
||||
|
||||
`pi-smart-fetch` 使用仓库内的修复版,不再直接加载上游 `0.3.17` 发布包。setup 会在 `resources/packages/pi-smart-fetch/` 安装运行时依赖,并自动迁移旧的 `npm:pi-smart-fetch` 配置;该修复为 ESM 构建注入 `createRequire(import.meta.url)`,避免 Node/Next.js 加载 `mime-types` 时触发 `Dynamic require of "path" is not supported`。
|
||||
|
||||
安装脚本默认不继承 `HTTP_PROXY` / `HTTPS_PROXY`,避免失效代理阻塞 npm。如果安装必须使用代理,Windows PowerShell 设置 `$env:PI_SETUP_USE_PROXY = "1"`,macOS/Linux 使用 `PI_SETUP_USE_PROXY=1 npm run setup`。
|
||||
|
||||
@@ -80,6 +83,12 @@ npm run dev
|
||||
|
||||
所有设备相关模型配置都位于被 Git 忽略的 `data/` 或 `.env` 中。
|
||||
|
||||
#### GPT Codex 快速模式
|
||||
|
||||
选择 `openai-codex` 的 GPT-5.5 或 GPT-5.6 模型后,输入框底部会显示闪电按钮。开启后按钮显示 `Fast mode ON`,并将该模型的 `serviceTier` 设为 `priority`,请求会使用 Codex 的快速档位;关闭后恢复普通档位。该设置写入 `data/agent/models.json`,下次请求立即生效,无需重启;非 GPT Codex 模型不会显示此按钮。
|
||||
|
||||
快速档位会增加费用:GPT-5.6 通常约为 2 倍,GPT-5.5 的 priority 费率约为 2.5 倍。Web UI 会在按钮提示中明确显示 `service_tier: priority` 和费用倍率。
|
||||
|
||||
### 可选环境变量
|
||||
|
||||
需要可选服务时,复制模板:
|
||||
@@ -112,10 +121,12 @@ Copy-Item .env.example .env
|
||||
|
||||
```text
|
||||
pi/ Pi 后端、Agent、CLI/TUI 源码
|
||||
pi-web/ Next.js Web 前端与 HTTP/SSE 服务
|
||||
server/ 独立后端服务(Hono + Node,托管 Pi 运行时与全部 /api 路由,端口 30142)
|
||||
pi-web/ Next.js 纯前端(界面渲染,/api 经 rewrites 转发到后端,端口 30141)
|
||||
config/ 可提交的缺省设置、MCP 和子代理策略
|
||||
resources/skills/ 应用级 Skill
|
||||
resources/extensions/ 应用级 Pi 扩展
|
||||
resources/packages/ 项目内置包(含 pi-smart-fetch 修复版)
|
||||
resources/prompts/ 提示词模板
|
||||
resources/themes/ 主题
|
||||
scripts/ 安装、启动、迁移与验证脚本
|
||||
@@ -138,7 +149,7 @@ data/workspaces/default/ 默认工作目录
|
||||
| `pi-lens@3.8.73` | LSP、AST、符号检索和项目诊断 | 模型按需激活代码智能工具;可用 `/lens-health`、`/lens-tools`、`/lens-map` 检查 |
|
||||
| `pi-memory@0.4.0` | Markdown 长期记忆、日志、临时工作区和恢复记录 | 默认轻量模式保留读写、状态和恢复工具,但不注册依赖 qmd 的 `memory_search`;文件位于 `data/agent/memory/` |
|
||||
| `pi-subagents@0.37.2` | 创建研究、规划或执行子代理 | 简单任务不委派,跨模块或可并行复杂任务自动判断;也可使用 `/run`、`/parallel` |
|
||||
| `pi-smart-fetch@0.3.17` | 抓取单个或批量 URL 内容 | 模型按需使用 `web_fetch` / `batch_web_fetch`,也提供给研究子代理 |
|
||||
| `pi-smart-fetch`(项目内修复版,基于 `0.3.17`) | 抓取单个或批量 URL 内容 | 模型按需使用 `web_fetch` / `batch_web_fetch`,也提供给研究子代理;修复 Node/Next.js ESM 加载兼容性 |
|
||||
| `@ayulab/pi-rewind@0.4.6` | 每轮前后创建代码检查点 | `/rewind` 恢复代码、会话或两者;`/checkpoint` 管理存储。自动恢复文件默认关闭 |
|
||||
| `@upstash/context7-pi@0.1.2` | 查询当前库、框架、SDK 和 API 文档 | 模型先解析库 ID,再按需查询文档;无 Key 可使用公共限额 |
|
||||
| `@narumitw/pi-retry@0.31.0` | 识别瞬时供应商错误和卡住的流 | 复用 Pi 内置重试;默认 180 秒无事件视为停滞,不增加正常请求的模型调用 |
|
||||
@@ -147,6 +158,8 @@ data/workspaces/default/ 默认工作目录
|
||||
|
||||
Windows 的 Playwright 不下载独立 Chromium;首次 `setup` 只缓存 MCP 的 Node.js 包,浏览器执行使用系统 Edge。macOS 的 setup 不安装或启用 Playwright;如需浏览器自动化,可在 Web UI 的 MCP 面板中手动添加并配置。
|
||||
|
||||
Windows 下的 `pi-subagents` 子进程由受管启动器自动处理:启动时将本地 `server/node_modules/.bin` 加入子进程 PATH,并把后端服务使用的 `pi-coding-agent` 包链接到受管 Profile,使子代理直接解析本地 `dist/cli.js`。这不会修改系统级 PATH,每次项目启动时会自动恢复。
|
||||
|
||||
#### 视觉子代理(vision)
|
||||
|
||||
DeepSeek 等纯文本模型不能接收图片。仓库内置 `vision` 子代理(`.agents/vision.md`):它通过 `vision` 工具调用视觉模型读取图片,把完整 OCR、版式结构与语义描述返回给主模型,主模型基于文本继续推理。视觉后端可插拔(本地 Ollama 或任意 OpenAI 兼容视觉 API)——仓库**不预设默认后端**,首次使用前需自行选择并配置。
|
||||
@@ -270,7 +283,7 @@ npm run storage:clean # 清空可重建缓存并删除全部孤儿检查点
|
||||
|
||||
## English
|
||||
|
||||
Pi Agent Integrated combines the Pi backend and Pi Web frontend into one independently cloneable and runnable repository. It preserves separate source boundaries while root commands handle dependency installation, Pi builds, managed-profile creation, plugin installation, and Web startup.
|
||||
Pi Agent Integrated combines the Pi backend and Pi Web frontend into one independently cloneable and runnable repository. It preserves separate source boundaries and uses a decoupled frontend/backend architecture: a standalone backend service (`server/`, port 30142) hosts the Pi runtime, agent sessions, and every API route, while the Next.js frontend (`pi-web/`, port 30141) renders the UI and proxies `/api` requests to the backend — frontend hot reloads and restarts never interrupt running agent sessions. Root commands handle dependency installation, Pi builds, managed-profile creation, plugin installation, and dual-process startup.
|
||||
|
||||
### How it differs from the two upstream projects
|
||||
|
||||
@@ -285,7 +298,7 @@ Pi Agent Integrated combines the Pi backend and Pi Web frontend into one indepen
|
||||
| Browser automation | User-integrated | No default browser MCP | Windows uses Edge by default; macOS leaves Playwright opt-in |
|
||||
| Recovery and durable context | Session tree and core state | Session UI | Rewind checkpoints, repository-local memory, automatic retry |
|
||||
| Multi-agent workflow | Extensible core | Renders tool calls | Automatic complexity policy for `pi-subagents` |
|
||||
| Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows/macOS restart |
|
||||
| Integration fixes | Not applicable | Upstream behavior | Handles ended-session state 404s, hides Next dev indicators, adds Windows/macOS restart, and auto-follows streaming output with a scroll-to-bottom button in the Web UI |
|
||||
|
||||
This repository is not a desktop installer or a public multi-user service. Its current release target is a technical Windows or macOS user who clones the project, supplies personal model credentials, and starts an isolated, extensible local Web agent from the command line.
|
||||
|
||||
@@ -311,7 +324,9 @@ npm run dev
|
||||
|
||||
Open <http://127.0.0.1:30141>.
|
||||
|
||||
Setup initializes the repository-local profile, builds Pi, links Pi Web to the local packages, installs and loads the pinned plugins, caches the Playwright MCP Node package and verifies system Edge on Windows, skips Playwright on macOS, and checks integration artifacts.
|
||||
Setup initializes the repository-local profile, builds Pi, installs the Pi Web frontend dependencies, installs the backend service (`server/`) with links to the local Pi packages, installs and loads the pinned plugins, installs the repository-local repaired `pi-smart-fetch` dependencies, caches the Playwright MCP Node package and verifies system Edge on Windows, skips Playwright on macOS, and checks integration artifacts.
|
||||
|
||||
The repaired `pi-smart-fetch` build is based on upstream `0.3.17`; it adds `createRequire(import.meta.url)` for the bundled CommonJS dependencies so Node/Next.js does not fail with `Dynamic require of "path" is not supported`. Existing managed profiles are migrated from `npm:pi-smart-fetch` to `resources/packages/pi-smart-fetch` automatically.
|
||||
|
||||
Child npm operations ignore `HTTP_PROXY` and `HTTPS_PROXY` by default to avoid stale proxy configuration. Set `$env:PI_SETUP_USE_PROXY = "1"` on Windows, or run `PI_SETUP_USE_PROXY=1 npm run setup` on macOS/Linux, when registry access requires the proxy.
|
||||
|
||||
@@ -326,6 +341,12 @@ The repository contains no author model endpoint, API key, OAuth token, or defau
|
||||
|
||||
Device-specific model configuration remains in ignored `data/` or `.env` files.
|
||||
|
||||
#### GPT Codex fast mode
|
||||
|
||||
When an `openai-codex` GPT-5.5 or GPT-5.6 model is selected, the lightning button appears beside the model selector. Enabling it shows `Fast mode ON` and sets that model's `serviceTier` to `priority`, using Codex's faster service tier; disabling it restores the default tier. The setting is written to `data/agent/models.json` and takes effect on the next request without a restart. Non-Codex models do not show the button.
|
||||
|
||||
Fast mode costs more: GPT-5.6 is typically about 2x, while GPT-5.5 priority pricing is about 2.5x. The Web UI tooltip displays `service_tier: priority` and the applicable credit multiplier.
|
||||
|
||||
### Optional environment configuration
|
||||
|
||||
macOS/Linux:
|
||||
@@ -356,10 +377,12 @@ Never commit `.env`, `data/`, real credentials, or screenshots containing creden
|
||||
|
||||
```text
|
||||
pi/ Pi backend, agent, and CLI/TUI source
|
||||
pi-web/ Next.js frontend and HTTP/SSE service
|
||||
server/ Standalone backend service (Hono + Node; hosts the Pi runtime and all /api routes; port 30142)
|
||||
pi-web/ Next.js frontend only (UI rendering; /api rewritten to the backend; port 30141)
|
||||
config/ Versioned settings, MCP, and subagent policy
|
||||
resources/skills/ Application skills
|
||||
resources/extensions/ Application Pi extensions
|
||||
resources/packages/ Repository-local packages (including repaired pi-smart-fetch)
|
||||
resources/prompts/ Prompt templates
|
||||
resources/themes/ Themes
|
||||
scripts/ Setup, launch, migration, and verification
|
||||
@@ -382,7 +405,7 @@ Versions are pinned in the platform defaults under `config/`: Windows uses `mcp.
|
||||
| `pi-lens@3.8.73` | LSP, AST, symbols, project diagnostics | The model activates code intelligence on demand; inspect with `/lens-health`, `/lens-tools`, `/lens-map` |
|
||||
| `pi-memory@0.4.0` | Markdown durable facts, logs, scratchpad, recovery | Lightweight mode retains read/write, status, and recovery tools but does not register qmd-dependent `memory_search`; files live under `data/agent/memory/` |
|
||||
| `pi-subagents@0.37.2` | Research, planning, execution subagents | Simple tasks stay local; complex or parallel work may delegate automatically; `/run` and `/parallel` remain available |
|
||||
| `pi-smart-fetch@0.3.17` | Single and batched URL retrieval | Provides `web_fetch` and `batch_web_fetch` to the main and research agents |
|
||||
| `pi-smart-fetch` (project-local repair, based on `0.3.17`) | Single and batched URL retrieval | Provides `web_fetch` and `batch_web_fetch` to the main and research agents; fixes Node/Next.js ESM loading compatibility |
|
||||
| `@ayulab/pi-rewind@0.4.6` | Per-turn code checkpoints | `/rewind` restores code, conversation, or both; `/checkpoint` manages storage. Automatic file restore is off |
|
||||
| `@upstash/context7-pi@0.1.2` | Current library, framework, SDK, API docs | Resolves a library ID and queries docs when needed; public quota works without a key |
|
||||
| `@narumitw/pi-retry@0.31.0` | Transient provider and stalled-stream classification | Uses Pi's built-in retry path; 180 seconds without events is a stall; no extra normal model calls |
|
||||
@@ -391,6 +414,8 @@ Versions are pinned in the platform defaults under `config/`: Windows uses `mcp.
|
||||
|
||||
On Windows, Playwright never downloads a standalone Chromium: setup caches only its Node package and browser execution uses system Edge. On macOS, setup does not install or enable Playwright; add it manually through the MCP panel if browser automation is needed.
|
||||
|
||||
On Windows, the managed launcher prepares `pi-subagents` child processes automatically: it prepends the local `server/node_modules/.bin` directory to the child PATH and links the backend's `pi-coding-agent` package into the managed profile, allowing subagents to resolve the local `dist/cli.js` directly. This does not modify the system-wide PATH and is recreated on each project launch.
|
||||
|
||||
#### Vision subagent
|
||||
|
||||
Text-only models such as DeepSeek cannot receive image attachments. The repository ships a `vision` subagent (`.agents/vision.md`) that calls a vision model through the `vision` tool and returns a full OCR, layout, and semantic description the main model can reason over. The vision backend is pluggable (local Ollama or any OpenAI-compatible vision API) — the repository does **not** ship a default backend; pick and configure one before first use.
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"npm:pi-mcp-adapter@2.15.0",
|
||||
"npm:pi-lens@3.8.73",
|
||||
"npm:pi-memory@0.4.0",
|
||||
"../../resources/packages/pi-smart-fetch",
|
||||
"npm:pi-subagents@0.37.2",
|
||||
"npm:pi-smart-fetch@0.3.17",
|
||||
"npm:@ayulab/pi-rewind@0.4.6",
|
||||
"npm:@upstash/context7-pi@0.1.2",
|
||||
"npm:@narumitw/pi-retry@0.31.0"
|
||||
|
||||
@@ -10,15 +10,16 @@ $supervisorLog = Join-Path $logsDirectory 'pi-web-supervisor.log'
|
||||
$restartRequestPath = Join-Path $projectRoot 'data\agent\restart-request.json'
|
||||
$webUrl = 'http://127.0.0.1:30141/'
|
||||
$restartRequestVersion = 1
|
||||
$maxResumeAttempts = 3
|
||||
|
||||
New-Item -ItemType Directory -Path $logsDirectory -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $restartRequestPath) -Force | Out-Null
|
||||
Set-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) supervisor started"
|
||||
Set-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) supervisor started" -Encoding UTF8
|
||||
|
||||
function Write-SupervisorLog {
|
||||
param([string]$Message)
|
||||
|
||||
Add-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) $Message"
|
||||
Add-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) $Message" -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-RestartRequest {
|
||||
@@ -72,6 +73,20 @@ function Remove-RestartRequest {
|
||||
Remove-Item -LiteralPath $restartRequestPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Archive-RestartRequest {
|
||||
param([object]$Request)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $restartRequestPath -PathType Leaf)) {
|
||||
return
|
||||
}
|
||||
|
||||
$requestId = (Get-RequestField $Request 'requestId') -replace '[^A-Za-z0-9-]', '_'
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$archivePath = Join-Path $logsDirectory "failed-restart-request-$timestamp-$requestId.json"
|
||||
Move-Item -LiteralPath $restartRequestPath -Destination $archivePath -Force
|
||||
Write-SupervisorLog "archived failed restart request at $archivePath"
|
||||
}
|
||||
|
||||
function Stop-ProcessTree {
|
||||
param([System.Diagnostics.Process]$Process)
|
||||
|
||||
@@ -149,6 +164,7 @@ $testInstructions
|
||||
}
|
||||
|
||||
$pendingRequest = Get-RestartRequest
|
||||
$resumeAttemptCount = 0
|
||||
|
||||
while ($true) {
|
||||
$process = $null
|
||||
@@ -176,18 +192,29 @@ while ($true) {
|
||||
try {
|
||||
Resume-AgentSession $pendingRequest
|
||||
$pendingRequest = $null
|
||||
$resumeAttemptCount = 0
|
||||
}
|
||||
catch {
|
||||
$resumeAttemptCount += 1
|
||||
Write-SupervisorLog "Agent resume failed: $($_.Exception.Message)"
|
||||
if ($resumeAttemptCount -ge $maxResumeAttempts) {
|
||||
Write-SupervisorLog "Agent resume abandoned after $resumeAttemptCount attempts"
|
||||
Archive-RestartRequest $pendingRequest
|
||||
$pendingRequest = $null
|
||||
$resumeAttemptCount = 0
|
||||
}
|
||||
else {
|
||||
$nextResumeAttempt = (Get-Date).AddSeconds(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $pendingRequest) {
|
||||
$candidate = Get-RestartRequest
|
||||
if ($candidate) {
|
||||
$pendingRequest = $candidate
|
||||
$resumeAttemptCount = 0
|
||||
Write-SupervisorLog "restart request $($candidate.requestId) detected"
|
||||
Stop-ProcessTree $process
|
||||
break
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"smoke:fresh-profile": "node scripts/smoke-fresh-profile.mjs",
|
||||
"smoke:isolation": "node scripts/smoke-isolation.mjs",
|
||||
"smoke:search": "node pi-web/scripts/searxng-smoke.mjs",
|
||||
"smoke:search": "node server/test/searxng-smoke.mjs",
|
||||
"predev": "npm run check",
|
||||
"dev": "node scripts/run.mjs dev",
|
||||
"restart": "node scripts/restart.mjs",
|
||||
@@ -30,9 +30,9 @@
|
||||
"storage:status": "node scripts/storage-maintenance.mjs status",
|
||||
"storage:maintain": "node scripts/storage-maintenance.mjs auto",
|
||||
"storage:clean": "node scripts/storage-maintenance.mjs clean",
|
||||
"typecheck": "node pi-web/node_modules/typescript/bin/tsc --noEmit -p pi-web/tsconfig.json",
|
||||
"typecheck": "npm --prefix server run typecheck && node pi-web/node_modules/typescript/bin/tsc --noEmit -p pi-web/tsconfig.json",
|
||||
"lint": "npm --prefix pi-web run lint",
|
||||
"test:managed": "node --test scripts/configure-pi-memory.test.mjs scripts/migrate-profile.test.mjs scripts/storage-maintenance.test.mjs pi-web/lib/app-runtime.test.mjs pi-web/lib/project-trust.test.mjs pi-web/lib/searxng-extension.test.mjs pi-web/lib/skill-lock.test.mjs pi-web/lib/skill-updates.test.mjs"
|
||||
"test:managed": "node --test scripts/configure-pi-memory.test.mjs scripts/migrate-profile.test.mjs scripts/storage-maintenance.test.mjs server/test/searxng-extension.test.mjs server/src/lib/app-runtime.test.mjs server/src/lib/bash-output.test.mjs server/src/lib/bounded-form-data.test.mjs server/src/lib/custom-ui-terminal.test.mjs server/src/lib/directory-browser.test.mjs server/src/lib/file-access.test.mjs server/src/lib/file-dirent.test.mjs server/src/lib/file-upload.test.mjs server/src/lib/git-changes.test.mjs server/src/lib/models-cache.test.mjs server/src/lib/project-trust.test.mjs server/src/lib/request-security.test.mjs server/src/lib/rpc-manager.test.mjs server/src/lib/session-file-references.test.mjs server/src/lib/session-path.test.mjs server/src/lib/session-reader.test.mjs server/src/lib/session-title.test.mjs server/src/lib/skill-lock.test.mjs server/src/lib/skill-updates.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
|
||||
+33
-54
@@ -15,72 +15,51 @@ Lint: `npm run lint`
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser Next.js Server AgentSession (in-process)
|
||||
Browser Next.js frontend Pi Agent Server
|
||||
│ │ │
|
||||
├─ GET /api/sessions ────▶ reads ~/.pi/agent/sessions/ │
|
||||
├─ GET /api/sessions/[id] reads .jsonl file directly │
|
||||
├─ GET /api/agent/running/events ───▶ running id SSE │
|
||||
│ │ │
|
||||
├─ send message ─────────▶ POST /api/agent/[id] │
|
||||
│ │ startRpcSession() ─────────▶│ createAgentSession()
|
||||
│ │ session.send(cmd) ─────────▶│ session.prompt()
|
||||
│ │ │
|
||||
├─ SSE connect ──────────▶ GET /api/agent/[id]/events │
|
||||
│ │ session.onEvent() ◀─────────│ session.subscribe()
|
||||
│◀── data: {...} ─────────│ │
|
||||
├─ GET/POST /api/* ────▶│ rewrite to :30142 ─────▶│ Hono routes
|
||||
├─ SSE connect ────────▶│ transparent proxy ─────▶│ AgentSession events
|
||||
│◀── data: {...} ───────│◀────────────────────────│
|
||||
```
|
||||
|
||||
**Session browsing** (read-only): reads `.jsonl` files through SDK `SessionManager` helpers and `lib/session-reader.ts` — no AgentSession created.
|
||||
**Sending a message**: `startRpcSession()` in `lib/rpc-manager.ts` creates an AgentSession in-process.
|
||||
`pi-web/` owns only React/Next.js UI code. All filesystem access, authentication,
|
||||
configuration, Pi packages, API routes, SSE streams, and AgentSession state live
|
||||
in `../server/`. The frontend proxies `/api/*` through `next.config.ts`; restarting
|
||||
or hot-reloading Next.js must not interrupt an active Agent session.
|
||||
|
||||
**Session browsing** (read-only): the backend reads `.jsonl` files through SDK
|
||||
`SessionManager` helpers and `server/src/lib/session-reader.ts`.
|
||||
**Sending a message**: `startRpcSession()` in `server/src/lib/rpc-manager.ts`
|
||||
creates and retains the AgentSession in the standalone backend process.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
app/api/
|
||||
sessions/route.ts GET list all sessions
|
||||
sessions/[id]/route.ts GET/PATCH/DELETE session
|
||||
sessions/[id]/context/route.ts GET ?leafId= — context for a specific leaf
|
||||
sessions/[id]/export/route.ts GET exported HTML for a session
|
||||
agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? }
|
||||
agent/[id]/route.ts GET state | POST any command
|
||||
agent/[id]/events/route.ts GET SSE stream
|
||||
agent/running/events/route.ts GET SSE stream of currently-running session ids
|
||||
auth/all-providers/route.ts GET API-key provider list
|
||||
auth/api-key/[provider]/route.ts GET/POST/DELETE provider API key status/storage
|
||||
auth/login/[provider]/route.ts GET OAuth/device-code SSE | POST manual code
|
||||
auth/logout/[provider]/route.ts POST OAuth logout
|
||||
auth/providers/route.ts GET OAuth provider list
|
||||
cwd/validate/route.ts POST validate/select a cwd
|
||||
default-cwd/route.ts POST create managed data/workspaces/default (standalone: ~/pi-cwd-YYYYMMDD)
|
||||
files/[...path]/route.ts GET file contents for viewer
|
||||
home/route.ts GET user home directory
|
||||
models/route.ts GET { models, modelList, defaultModel }
|
||||
models-config/route.ts GET/PUT — read/write ~/.pi/agent/models.json
|
||||
models-config/catalog/route.ts GET models.dev pricing presets
|
||||
models-config/discover/route.ts POST fetch a configured provider's upstream model list
|
||||
models-config/test/route.ts POST test a configured model/provider
|
||||
plugins/route.ts GET/POST package plugin management
|
||||
skills/route.ts GET/PATCH loaded skills and disable-model-invocation
|
||||
skills/install/route.ts POST install skills through npx skills add
|
||||
skills/search/route.ts GET/POST skills.sh search
|
||||
worktrees/route.ts GET/POST/DELETE git worktrees
|
||||
../server/src/
|
||||
index.ts Hono server, health endpoint, route registration
|
||||
security.ts host/origin gate for every /api request
|
||||
routes/agent.ts Agent creation, commands, state, and SSE
|
||||
routes/auth.ts OAuth/device-code and API-key routes
|
||||
routes/config.ts extensions, MCP, vision, and plugin configuration
|
||||
routes/files.ts guarded file reading, preview, download, and upload
|
||||
routes/misc.ts cwd, runtime, file index, worktrees, and project trust
|
||||
routes/models.ts Git state, models, discovery, catalog, and tests
|
||||
routes/sessions.ts session listing, context, naming, export, and deletion
|
||||
routes/skills.ts skill listing, search, install, and update
|
||||
lib/rpc-manager.ts AgentSessionWrapper registry and startRpcSession()
|
||||
lib/session-reader.ts SessionManager wrappers and session path cache
|
||||
|
||||
lib/
|
||||
agent-client.ts typed fetch helper for /api/agent commands
|
||||
draft-store.ts local draft persistence helpers
|
||||
file-access.ts allowed file roots for /api/files and worktrees
|
||||
file-paths.ts client/server path encoding helpers
|
||||
file-paths.ts client-side path encoding helpers
|
||||
markdown.ts shared markdown helpers
|
||||
npx.ts npx runner used by skill install
|
||||
pi-types.ts local structural types for pi SDK objects
|
||||
rpc-manager.ts AgentSessionWrapper + registry + startRpcSession
|
||||
session-reader.ts SessionManager wrappers + path cache + buildSessionContext adapter
|
||||
pi-types.ts structural copies of API/session types; no Pi dependency
|
||||
tool-presets.ts PRESET_NONE/DEFAULT/FULL + getPresetFromTools()
|
||||
types.ts shared TypeScript types
|
||||
normalize.ts normalizeToolCalls() — field name mismatch between file format and our types
|
||||
worktree.ts project/worktree resolution and git worktree operations
|
||||
|
||||
components/
|
||||
AppShell.tsx layout + URL state + tab management
|
||||
@@ -111,9 +90,9 @@ hooks/
|
||||
|
||||
## Key Design Decisions & Traps
|
||||
|
||||
### AgentSession lifecycle (`lib/rpc-manager.ts`)
|
||||
### AgentSession lifecycle (`server/src/lib/rpc-manager.ts`)
|
||||
- One `AgentSessionWrapper` per session id, keyed in `globalThis.__piSessions`
|
||||
- `globalThis` survives Next.js hot-reload; plain module-level Map does not
|
||||
- The standalone backend process survives Next.js hot reloads and restarts
|
||||
- Idle timeout: 10 minutes. Concurrent `startRpcSession()` calls share a single start Promise (`globalThis.__piStartLocks`)
|
||||
|
||||
### Fork must destroy the wrapper immediately
|
||||
@@ -144,12 +123,12 @@ On `ChatWindow` mount, `GET /api/agent/[id]` is called. If `state.isStreaming ==
|
||||
Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `auto_compaction_start` / `auto_compaction_end`. `handleAgentEvent` accepts both sets to keep `isCompacting` in sync. Manual compact is a blocking POST — the button stays disabled until the response returns.
|
||||
|
||||
### Running state SSE + reconciliation
|
||||
- The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `lib/rpc-manager.ts`, so running badges update without polling.
|
||||
- The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `server/src/lib/rpc-manager.ts`, so running badges update without polling.
|
||||
- `useAgentSession` still treats per-session SSE as primary for chat events, but while a run is active it periodically calls `GET /api/agent/[id]` and also reconciles on `visibilitychange`/`online`. This fixes missed `agent_end` events from background tabs or half-open connections.
|
||||
- Prompt runs use a monotonic run id; late SSE or slow reconciliation responses from an old run must be ignored so they cannot resurrect stale streaming bubbles.
|
||||
|
||||
### Worktrees and project grouping
|
||||
- `lib/worktree.ts` resolves linked worktree top-levels back to the main repo `projectRoot`; `listAllSessions()` attaches that to each `SessionInfo` so all worktrees for one repo are grouped together in the sidebar.
|
||||
- `server/src/lib/worktree.ts` resolves linked worktree top-levels back to the main repo `projectRoot`; `listAllSessions()` attaches that to each `SessionInfo` so all worktrees for one repo are grouped together in the sidebar.
|
||||
- Worktree operations are served by `/api/worktrees` and guarded by the same allowed-root rules as `/api/files`.
|
||||
- New worktrees are created under `<repoRoot>-worktrees/<sanitized-branch>`. Existing branches are reused; otherwise `git worktree add -b` creates the branch.
|
||||
- Removing a dirty worktree returns `409` with `{ dirty: true }` so the UI can ask before retrying with `force`.
|
||||
@@ -169,7 +148,7 @@ Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `au
|
||||
- `ModelsConfig` combines models from `~/.pi/agent/models.json` with provider auth status from pi's `AuthStorage`/`ModelRegistry`.
|
||||
- OAuth/device-code/manual-code flows are streamed by `GET /api/auth/login/[provider]`; manual code responses POST back with a short-lived token stored in `globalThis.__piLoginCallbacks`.
|
||||
- API-key routes store and remove keys through `AuthStorage`. Status endpoints must never return the raw key.
|
||||
- The model test route is `app/api/models-config/test/route.ts`; `app/api/models/test/` is not a real route.
|
||||
- The model test endpoint is registered in `server/src/routes/models.ts` as `/api/models-config/test`; `/api/models/test` is not a real route.
|
||||
|
||||
### Completion sound
|
||||
- `hooks/useAudio.ts` stores the toggle in `localStorage` as `pi-sound-enabled` and reuses one `AudioContext`.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
MAX_INLINE_BASH_OUTPUT_BYTES,
|
||||
openRegularFileNoFollow,
|
||||
readUtf8FileWithinLimit,
|
||||
resolveBashOutputPath,
|
||||
} from "@/lib/bash-output";
|
||||
import { isBashOutputPathReferencedBySession } from "@/lib/session-file-references";
|
||||
|
||||
// GET /api/agent/[id]/bash-output?path=<absPath>
|
||||
// Reads a bash output temp file referenced by this session. Inline display is
|
||||
// size-limited; download responses stream the file without buffering it.
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
let path: string | null = null;
|
||||
let download = false;
|
||||
try {
|
||||
const url = new URL(_req.url);
|
||||
path = url.searchParams.get("path");
|
||||
download = url.searchParams.get("download") === "1";
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: "path required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolved = resolveBashOutputPath(path, tmpdir());
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: "invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!await isBashOutputPathReferencedBySession(resolved, id)) {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (download) {
|
||||
const { handle } = await openRegularFileNoFollow(resolved);
|
||||
const stream = Readable.toWeb(handle.createReadStream()) as ReadableStream<Uint8Array>;
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "attachment; filename=\"bash-output.log\"",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await readUtf8FileWithinLimit(resolved);
|
||||
if (result.tooLarge) {
|
||||
return NextResponse.json({
|
||||
error: `Full output is too large to display (limit ${MAX_INLINE_BASH_OUTPUT_BYTES} bytes)`,
|
||||
data: { size: result.size, maxBytes: MAX_INLINE_BASH_OUTPUT_BYTES },
|
||||
}, { status: 413 });
|
||||
}
|
||||
return NextResponse.json({ success: true, data: { output: result.content } });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "full output unavailable" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/agent/[id]/events - SSE stream of agent events
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
// Fast path: already-running session
|
||||
let session = getRpcSession(id);
|
||||
if (!session || !session.isAlive()) {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return new Response("Session not found", { status: 404 });
|
||||
}
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
try {
|
||||
({ session } = await startRpcSession(id, filePath, cwd));
|
||||
} catch (error) {
|
||||
return new Response(`Failed to start agent: ${error}`, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encode = (data: unknown) => {
|
||||
const text = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
};
|
||||
|
||||
// Send initial connected event
|
||||
encode({ type: "connected", sessionId: id });
|
||||
|
||||
const unsubscribe = session.onEvent((event) => {
|
||||
encode(event);
|
||||
});
|
||||
|
||||
// Heartbeat every 30s to prevent server/proxy timeout (Next.js default ~120-150s)
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(":\n\n"));
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Cleanup when client disconnects
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
controller.close();
|
||||
};
|
||||
|
||||
// Detect client disconnect via abort signal
|
||||
req.signal?.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
import { startRpcSession, getRpcSession } from "@/lib/rpc-manager";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// POST /api/agent/[id] - Send a command to an existing session
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const body = await req.json() as { type: string; [key: string]: unknown };
|
||||
|
||||
// Fast path: already-running session
|
||||
const existing = getRpcSession(id);
|
||||
if (existing?.isAlive()) {
|
||||
const result = await existing.send(body);
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
}
|
||||
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
|
||||
const { session } = await startRpcSession(id, filePath, cwd);
|
||||
const result = await session.send(body);
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/agent/[id] - Get current agent state
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const session = getRpcSession(id);
|
||||
if (!session || !session.isAlive()) {
|
||||
return NextResponse.json({ running: false });
|
||||
}
|
||||
|
||||
const state = await session.send({ type: "get_state" });
|
||||
return NextResponse.json({ running: true, state });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
import { invalidateSessionListCache } from "@/lib/session-reader";
|
||||
import { startRpcSession } from "@/lib/rpc-manager";
|
||||
// POST /api/agent/new body: { cwd: string; type: string; message?: string; ... }
|
||||
// Spawns a brand-new pi session. Most calls immediately send the first command;
|
||||
// type:"ensure_session" only creates the runtime so clients can query commands.
|
||||
// Returns { sessionId, data } where sessionId is pi's real session id.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; [key: string]: unknown };
|
||||
const { cwd, ...command } = body;
|
||||
|
||||
if (!cwd || typeof cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!existsSync(cwd)) {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use a one-time key so startRpcSession's lock doesn't conflict with real session ids
|
||||
const { provider, modelId, toolNames, thinkingLevel, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: string; [key: string]: unknown };
|
||||
|
||||
// Must be unique per request: startRpcSession coalesces concurrent callers
|
||||
// that share a key onto one session. Date.now() (ms resolution) collides for
|
||||
// requests in the same millisecond, merging two new sessions into one.
|
||||
const tempKey = `__new__${randomUUID()}`;
|
||||
const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, toolNames);
|
||||
|
||||
// Keep the files-route allowed-roots cache (see app/api/files/[...path]/route.ts)
|
||||
// in sync so the new cwd is immediately readable via /api/files. Without this,
|
||||
// a file request under a brand-new cwd would 403 for up to the cache TTL.
|
||||
allowFileRoot(cwd);
|
||||
invalidateSessionListCache();
|
||||
|
||||
// Apply pre-selected model before sending the prompt
|
||||
if (provider && modelId) {
|
||||
await session.send({ type: "set_model", provider, modelId });
|
||||
}
|
||||
|
||||
// Apply pre-selected thinking level before sending the prompt
|
||||
if (thinkingLevel) {
|
||||
await session.send({ type: "set_thinking_level", level: thinkingLevel });
|
||||
}
|
||||
|
||||
if (promptCommand.type === "ensure_session") {
|
||||
return NextResponse.json({ success: true, sessionId: realSessionId, data: null });
|
||||
}
|
||||
|
||||
const result = await session.send(promptCommand);
|
||||
|
||||
return NextResponse.json({ success: true, sessionId: realSessionId, data: result });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { getRunningRpcSessionIds, subscribeRunningSessions } from "@/lib/rpc-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/agent/running/events - SSE stream of the set of currently-running
|
||||
// session ids. Pushes an update whenever any session starts or stops working,
|
||||
// so the sidebar never has to poll.
|
||||
export async function GET(req: Request) {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encode = (data: unknown) => {
|
||||
const text = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
};
|
||||
|
||||
// Subscribe BEFORE taking the initial snapshot so no state change can slip
|
||||
// through the gap between snapshot and subscription.
|
||||
const unsubscribe = subscribeRunningSessions((ids) => {
|
||||
try {
|
||||
encode({ type: "running", runningSessionIds: ids });
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
});
|
||||
|
||||
// Initial snapshot so the client renders the correct state immediately.
|
||||
// (A duplicate frame here is harmless: the client just sets the same set.)
|
||||
encode({ type: "running", runningSessionIds: getRunningRpcSessionIds() });
|
||||
|
||||
// Heartbeat to keep the connection alive through proxies/timeouts.
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(":\n\n"));
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
};
|
||||
|
||||
req.signal?.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Providers that use OAuth — handled separately via /api/auth/providers
|
||||
const OAUTH_PROVIDER_IDS = new Set(["anthropic", "github-copilot", "openai-codex"]);
|
||||
|
||||
export async function GET() {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const all = modelRuntime.getModels();
|
||||
|
||||
// Deduplicate by provider, skip OAuth-only providers and custom providers (source=models_json_key)
|
||||
const seen = new Set<string>();
|
||||
const result: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
configured: boolean;
|
||||
source?: string;
|
||||
modelCount: number;
|
||||
}[] = [];
|
||||
|
||||
for (const provider of modelRuntime.getProviders()) {
|
||||
if (seen.has(provider.id)) continue;
|
||||
seen.add(provider.id);
|
||||
if (OAUTH_PROVIDER_IDS.has(provider.id) || !provider.auth.apiKey?.login) continue;
|
||||
const status = modelRuntime.getProviderAuthStatus(provider.id);
|
||||
// Skip providers whose key comes from models.json (those are custom providers)
|
||||
if (status.source === "models_json_key") continue;
|
||||
const modelCount = all.filter((model) => model.provider === provider.id).length;
|
||||
result.push({
|
||||
id: provider.id,
|
||||
displayName: provider.name,
|
||||
configured: status.configured,
|
||||
source: status.source,
|
||||
modelCount,
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ providers: result });
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { NextResponse } from "next/server";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ provider: string }> };
|
||||
|
||||
// GET /api/auth/api-key/[provider] — returns auth status (never returns the actual key)
|
||||
export async function GET(_req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const status = modelRuntime.getProviderAuthStatus(provider);
|
||||
const displayName = modelRuntime.getProvider(provider)?.name ?? provider;
|
||||
const models = modelRuntime.getModels(provider).length;
|
||||
return NextResponse.json({ provider, displayName, configured: status.configured, source: status.source, models });
|
||||
}
|
||||
|
||||
// POST /api/auth/api-key/[provider] body: { apiKey: string }
|
||||
export async function POST(req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
try {
|
||||
const { apiKey } = await req.json() as { apiKey?: string };
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
return NextResponse.json({ error: "apiKey is required" }, { status: 400 });
|
||||
}
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
let keySubmitted = false;
|
||||
await modelRuntime.login(provider, "api_key", {
|
||||
notify: () => {},
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
const keyOption = prompt.options.find((option) => option.id === "api-key" || option.id === "bearer-token");
|
||||
if (keyOption) return keyOption.id;
|
||||
throw new Error(`${provider} requires interactive authentication setup`);
|
||||
}
|
||||
if (!keySubmitted && prompt.type === "secret") {
|
||||
keySubmitted = true;
|
||||
return apiKey.trim();
|
||||
}
|
||||
throw new Error(`${provider} requires additional authentication settings`);
|
||||
},
|
||||
});
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/auth/api-key/[provider] — removes stored API key
|
||||
export async function DELETE(_req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
try {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
await modelRuntime.logout(provider);
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// In-memory registry: loginToken -> resolve/reject for the manualCodeInput promise
|
||||
declare global {
|
||||
var __piLoginCallbacks: Map<string, { resolve: (v: string) => void; reject: (e: Error) => void }> | undefined;
|
||||
}
|
||||
|
||||
function getCallbackRegistry() {
|
||||
if (!globalThis.__piLoginCallbacks) globalThis.__piLoginCallbacks = new Map();
|
||||
return globalThis.__piLoginCallbacks;
|
||||
}
|
||||
|
||||
// POST /api/auth/login/[provider] — frontend sends redirect URL or auth code
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const { token, code } = (await req.json()) as { token?: string; code?: string };
|
||||
|
||||
if (!token || !code) {
|
||||
return Response.json({ error: "token and code required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const registry = getCallbackRegistry();
|
||||
const callbacks = registry.get(token);
|
||||
if (!callbacks) {
|
||||
return Response.json({ error: "No pending login for token" }, { status: 404 });
|
||||
}
|
||||
// Verify token belongs to this provider (token format: "<provider>-<ts>-<random>")
|
||||
if (!token.startsWith(`${provider}-`)) {
|
||||
return Response.json({ error: "Token does not match provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
callbacks.resolve(code);
|
||||
registry.delete(token);
|
||||
return Response.json({ ok: true, provider });
|
||||
}
|
||||
|
||||
// GET /api/auth/login/[provider] — SSE stream for OAuth flow
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const send = (controller: ReadableStreamDefaultController, data: unknown) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
|
||||
// AbortController propagates client disconnect into ModelRuntime.login().
|
||||
const abort = new AbortController();
|
||||
req.signal.addEventListener("abort", () => abort.abort());
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
|
||||
send(controller, { type: "error", message: `Unknown provider: ${provider}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = getCallbackRegistry();
|
||||
const activeTokens = new Set<string>();
|
||||
let pendingManualRequest: { token: string; promise: Promise<string> } | undefined;
|
||||
|
||||
const createClientInputRequest = () => {
|
||||
const token = `${provider}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
activeTokens.add(token);
|
||||
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
registry.set(token, {
|
||||
resolve: (value) => {
|
||||
activeTokens.delete(token);
|
||||
registry.delete(token);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
activeTokens.delete(token);
|
||||
registry.delete(token);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { token, promise };
|
||||
};
|
||||
|
||||
const getManualInputRequest = () => {
|
||||
if (!pendingManualRequest) {
|
||||
pendingManualRequest = createClientInputRequest();
|
||||
pendingManualRequest.promise
|
||||
.finally(() => {
|
||||
pendingManualRequest = undefined;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return pendingManualRequest;
|
||||
};
|
||||
|
||||
// Cleanup: remove pending token and abort any waiting promise
|
||||
const cleanup = () => {
|
||||
for (const token of activeTokens) {
|
||||
registry.get(token)?.reject(new Error("Login cancelled"));
|
||||
registry.delete(token);
|
||||
}
|
||||
activeTokens.clear();
|
||||
};
|
||||
|
||||
// Also cancel on client disconnect
|
||||
abort.signal.addEventListener("abort", cleanup);
|
||||
|
||||
try {
|
||||
await modelRuntime.login(provider, "oauth", {
|
||||
prompt: async (prompt: AuthPrompt) => {
|
||||
const request = prompt.type === "manual_code"
|
||||
? getManualInputRequest()
|
||||
: createClientInputRequest();
|
||||
if (prompt.type === "select") {
|
||||
send(controller, {
|
||||
type: "select_request",
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
token: request.token,
|
||||
});
|
||||
} else {
|
||||
send(controller, {
|
||||
type: "prompt_request",
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder ?? null,
|
||||
token: request.token,
|
||||
});
|
||||
}
|
||||
return request.promise;
|
||||
},
|
||||
notify: (event: AuthEvent) => {
|
||||
if (event.type === "auth_url") {
|
||||
const request = getManualInputRequest();
|
||||
send(controller, {
|
||||
type: "auth",
|
||||
url: event.url,
|
||||
instructions: event.instructions ?? null,
|
||||
token: request.token,
|
||||
});
|
||||
} else if (event.type === "device_code") {
|
||||
send(controller, {
|
||||
type: "device_code",
|
||||
userCode: event.userCode,
|
||||
verificationUri: event.verificationUri,
|
||||
intervalSeconds: event.intervalSeconds ?? null,
|
||||
expiresInSeconds: event.expiresInSeconds ?? null,
|
||||
});
|
||||
} else {
|
||||
send(controller, { type: "progress", message: event.message });
|
||||
}
|
||||
},
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
invalidateModelsCache();
|
||||
send(controller, { type: "success" });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg !== "Login cancelled") {
|
||||
send(controller, { type: "error", message: msg });
|
||||
} else {
|
||||
send(controller, { type: "cancelled" });
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
|
||||
return Response.json({ error: `Unknown provider: ${provider}` }, { status: 400 });
|
||||
}
|
||||
await modelRuntime.logout(provider);
|
||||
invalidateModelsCache();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const credentials = await modelRuntime.listCredentials();
|
||||
const loggedInProviders = new Set(
|
||||
credentials.filter((credential) => credential.type === "oauth").map((credential) => credential.providerId),
|
||||
);
|
||||
const providers = modelRuntime.getProviders().filter((provider) => provider.auth.oauth);
|
||||
|
||||
const EXCLUDED = new Set(["anthropic"]);
|
||||
const DISPLAY_NAMES: Record<string, string> = {
|
||||
"openai-codex": "ChatGPT Plus/Pro",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
};
|
||||
|
||||
const result = await Promise.all(
|
||||
providers
|
||||
.filter((p) => !EXCLUDED.has(p.id))
|
||||
.map(async (p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
name: DISPLAY_NAMES[p.id] ?? p.name,
|
||||
usesCallbackServer: false,
|
||||
loggedIn: loggedInProviders.has(p.id),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return Response.json({ providers: result });
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { stat } from "fs/promises";
|
||||
import {
|
||||
getBrowseStartDirectory,
|
||||
getParentDirectory,
|
||||
listDirectories,
|
||||
resolveDirectory,
|
||||
} from "@/lib/directory-browser";
|
||||
|
||||
// GET /api/cwd/browse?path=...:列出文件系统中的可读子目录。
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const requested = request.nextUrl.searchParams.get("path")?.trim();
|
||||
const candidate = getBrowseStartDirectory(requested);
|
||||
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await resolveDirectory(candidate);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory does not exist" }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
const directoryStat = await stat(resolved);
|
||||
if (!directoryStat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Path is not a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
const directories = await listDirectories(resolved);
|
||||
|
||||
return NextResponse.json({
|
||||
path: resolved,
|
||||
parentPath: getParentDirectory(resolved),
|
||||
directories,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { statSync, type Stats } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { isAbsolute, resolve } from "path";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
if (cwd === "~") return homedir();
|
||||
if (cwd.startsWith("~/")) return resolve(homedir(), cwd.slice(2));
|
||||
return isAbsolute(cwd) ? cwd : resolve(cwd);
|
||||
}
|
||||
|
||||
// POST /api/cwd/validate body: { cwd: string }
|
||||
// Validates a candidate workspace before the UI selects it.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: unknown };
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd.trim() : "";
|
||||
|
||||
if (!cwd) {
|
||||
return NextResponse.json({ error: "Path is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedCwd = normalizeCwd(cwd);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = statSync(normalizedCwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: `Path is not a directory: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
allowFileRoot(normalizedCwd);
|
||||
return NextResponse.json({ success: true, cwd: normalizedCwd });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdirSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
|
||||
// POST /api/default-cwd
|
||||
// Managed app: creates <dataDir>/workspaces/default.
|
||||
// Standalone pi-web: preserves the upstream ~/pi-cwd-<YYYYMMDD> behavior.
|
||||
export async function POST() {
|
||||
try {
|
||||
const dir = isManagedRuntime()
|
||||
? join(getManagedRuntimePaths().dataDir, "workspaces", "default")
|
||||
: join(
|
||||
homedir(),
|
||||
`pi-cwd-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
allowFileRoot(dir);
|
||||
return NextResponse.json({ cwd: dir });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import { basename, dirname, extname, join, relative, resolve } from "path";
|
||||
import { DefaultPackageManager, getAgentDir, type ResolvedResource } from "@earendil-works/pi-coding-agent";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { getManagedRuntimePaths, isManagedRuntime, createAppSettingsManager } from "@/lib/app-runtime";
|
||||
import { getProjectTrustStatus } from "@/lib/project-trust";
|
||||
import type { ExtensionInfo, ExtensionsResponse, PluginDiagnostic } from "@/lib/api-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function extensionName(path: string): string {
|
||||
const file = basename(path);
|
||||
const extension = extname(file);
|
||||
if (/^index\.(ts|js)$/.test(file)) return basename(dirname(path));
|
||||
return extension ? file.slice(0, -extension.length) : file;
|
||||
}
|
||||
|
||||
function extensionFiles(root: string): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
try {
|
||||
const stats = statSync(root);
|
||||
if (stats.isFile()) return /\.(?:ts|js)$/.test(root) ? [root] : [];
|
||||
if (!stats.isDirectory()) return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) files.push(...extensionFiles(path));
|
||||
else if (entry.isFile() && /\.(?:ts|js)$/.test(entry.name)) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function infoFromResource(resource: ResolvedResource, scopeOverride?: ExtensionInfo["scope"]): ExtensionInfo {
|
||||
const baseDir = resource.metadata.baseDir ?? dirname(resource.path);
|
||||
const rel = relative(baseDir, resource.path);
|
||||
return {
|
||||
name: extensionName(resource.path),
|
||||
path: resource.path,
|
||||
relativePath: rel && !rel.startsWith("..") ? rel : resource.path,
|
||||
source: scopeOverride === "builtin" ? "resources" : resource.metadata.source,
|
||||
scope: scopeOverride ?? (resource.metadata.scope === "project" ? "project" : "global"),
|
||||
status: resource.enabled ? "enabled" : "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function blockedInfo(path: string): ExtensionInfo {
|
||||
return {
|
||||
name: extensionName(path),
|
||||
path,
|
||||
relativePath: relative(dirname(dirname(path)), path),
|
||||
source: "project",
|
||||
scope: "project",
|
||||
status: "blocked",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
let cwd: string | null;
|
||||
try {
|
||||
cwd = new URL(req.url).searchParams.get("cwd");
|
||||
} catch {
|
||||
return Response.json({ error: "invalid request URL" }, { status: 400 });
|
||||
}
|
||||
if (!cwd) return Response.json({ error: "cwd required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const trust = getProjectTrustStatus(cwd, agentDir);
|
||||
const settingsManager = createAppSettingsManager(cwd, agentDir, trust.trusted);
|
||||
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
const diagnostics: PluginDiagnostic[] = [];
|
||||
const byPath = new Map<string, ExtensionInfo>();
|
||||
|
||||
try {
|
||||
const resolved = await packageManager.resolve(async () => "skip");
|
||||
for (const resource of resolved.extensions) {
|
||||
// Package-provided extensions belong to the plugin panel, not here.
|
||||
if (resource.metadata.origin !== "top-level") continue;
|
||||
byPath.set(resource.path, infoFromResource(resource));
|
||||
}
|
||||
} catch (error) {
|
||||
diagnostics.push({ type: "error", message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
if (isManagedRuntime()) {
|
||||
const resourcesRoot = resolve(getManagedRuntimePaths().resourcesDir, "extensions");
|
||||
const builtIn = await packageManager.resolveExtensionSources([resourcesRoot], { temporary: true });
|
||||
for (const resource of builtIn.extensions) {
|
||||
byPath.set(resource.path, infoFromResource(resource, "builtin"));
|
||||
}
|
||||
}
|
||||
|
||||
const projectExtensionsRoot = join(cwd, ".pi", "extensions");
|
||||
if (trust.requiresTrust && !trust.trusted) {
|
||||
for (const path of extensionFiles(projectExtensionsRoot)) {
|
||||
byPath.set(path, blockedInfo(path));
|
||||
}
|
||||
if (byPathHasScope(byPath, "project")) {
|
||||
diagnostics.push({ type: "warning", source: "project", message: "Project extensions are blocked until this project is trusted." });
|
||||
}
|
||||
}
|
||||
|
||||
const scopeOrder: Record<ExtensionInfo["scope"], number> = { project: 0, builtin: 1, global: 2 };
|
||||
const extensions = [...byPath.values()].sort((a, b) => (
|
||||
scopeOrder[a.scope] - scopeOrder[b.scope] || a.name.localeCompare(b.name) || a.path.localeCompare(b.path)
|
||||
));
|
||||
return Response.json({ extensions, diagnostics, projectResourcesLoaded: trust.trusted } satisfies ExtensionsResponse);
|
||||
} catch (error) {
|
||||
return Response.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function byPathHasScope(entries: Map<string, ExtensionInfo>, scope: ExtensionInfo["scope"]): boolean {
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.scope === scope) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getAllowedFileRoots,
|
||||
isExistingFilePathAllowed,
|
||||
isFilePathAllowed,
|
||||
isWindowsAbsolutePath,
|
||||
} from "@/lib/file-access";
|
||||
import { buildEntriesFromFiles, filterFileEntries, type FileIndexEntry } from "@/lib/file-fuzzy";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Same skip lists as /api/files — only used for the non-git readdir fallback.
|
||||
// Git-tracked repos rely on .gitignore instead (matches the TUI's fd behavior).
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store",
|
||||
]);
|
||||
|
||||
const IGNORED_SUFFIXES = [".pyc"];
|
||||
|
||||
/** Cap on the plain (no-query) response used as the client-side index */
|
||||
const MAX_FILES = 5000;
|
||||
/** Hard caps on the full in-memory listing that ?q= searches against */
|
||||
const GIT_HARD_CAP = 200_000;
|
||||
const WALK_HARD_CAP = 50_000;
|
||||
const MAX_WALK_DEPTH = 8;
|
||||
const MAX_QUERY_LENGTH = 500;
|
||||
const CACHE_TTL_MS = 10_000;
|
||||
const CACHE_MAX_ENTRIES = 20;
|
||||
|
||||
interface FileListing {
|
||||
/** Full listing up to the hard cap (not the client cap) */
|
||||
files: string[];
|
||||
/** True when even the hard cap was exceeded */
|
||||
hardTruncated: boolean;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
listing: FileListing;
|
||||
/** Derived lazily on the first ?q= search against this listing */
|
||||
entries?: FileIndexEntry[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// Per-cwd cache on globalThis so it survives Next.js hot-reload; the @ menu
|
||||
// re-requests on every open and searches on every keystroke, so listings must
|
||||
// not be recomputed within a short window.
|
||||
declare global {
|
||||
var __piFileIndexCache: Map<string, CacheEntry> | undefined;
|
||||
}
|
||||
|
||||
function getIndexCache(): Map<string, CacheEntry> {
|
||||
if (!globalThis.__piFileIndexCache) globalThis.__piFileIndexCache = new Map();
|
||||
return globalThis.__piFileIndexCache;
|
||||
}
|
||||
|
||||
async function listWithGit(cwd: string): Promise<FileListing | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
{ timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } },
|
||||
);
|
||||
const all = stdout.split("\0").filter(Boolean);
|
||||
if (all.length > GIT_HARD_CAP) {
|
||||
return { files: all.slice(0, GIT_HARD_CAP), hardTruncated: true };
|
||||
}
|
||||
return { files: all, hardTruncated: false };
|
||||
} catch {
|
||||
// Not a git repo (or git unavailable) — caller falls back to readdir walk.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listWithWalk(cwd: string): FileListing {
|
||||
const files: string[] = [];
|
||||
// BFS so shallow files win when the cap truncates the listing.
|
||||
const queue: Array<{ abs: string; rel: string; depth: number }> = [{ abs: cwd, rel: "", depth: 0 }];
|
||||
while (queue.length > 0) {
|
||||
const { abs, rel, depth } = queue.shift()!;
|
||||
let dirents: fs.Dirent[];
|
||||
try {
|
||||
dirents = fs.readdirSync(abs, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of dirents) {
|
||||
if (IGNORED_NAMES.has(d.name) || IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) continue;
|
||||
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||
if (d.isDirectory()) {
|
||||
if (depth + 1 <= MAX_WALK_DEPTH) {
|
||||
queue.push({ abs: path.join(abs, d.name), rel: childRel, depth: depth + 1 });
|
||||
}
|
||||
} else if (d.isFile()) {
|
||||
if (files.length >= WALK_HARD_CAP) {
|
||||
return { files, hardTruncated: true };
|
||||
}
|
||||
files.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, hardTruncated: false };
|
||||
}
|
||||
|
||||
// GET /api/file-index?cwd=/abs/path[&q=query]
|
||||
// Without q: { files: string[] (relative to cwd, capped at MAX_FILES),
|
||||
// truncated: boolean } — the client-side index for local filtering.
|
||||
// With q: { matches: { path, isDir }[] } — ranked against the FULL listing so
|
||||
// repos larger than MAX_FILES still find deep files (cap applied after
|
||||
// matching, like the TUI passing the query to fd).
|
||||
// Guarded by the same allow-list as /api/files.
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const cwd = req.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
const query = req.nextUrl.searchParams.get("q")?.slice(0, MAX_QUERY_LENGTH) ?? "";
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(cwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const cache = getIndexCache();
|
||||
const now = Date.now();
|
||||
let cached = cache.get(cwd);
|
||||
if (!cached || cached.expiresAt <= now) {
|
||||
const listing = (await listWithGit(cwd)) ?? listWithWalk(cwd);
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.expiresAt <= now) cache.delete(key);
|
||||
}
|
||||
if (cache.size >= CACHE_MAX_ENTRIES) cache.clear();
|
||||
cached = { listing, expiresAt: now + CACHE_TTL_MS };
|
||||
cache.set(cwd, cached);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
cached.entries ??= buildEntriesFromFiles(cached.listing.files);
|
||||
return NextResponse.json({ matches: filterFileEntries(cached.entries, query) });
|
||||
}
|
||||
|
||||
const { files, hardTruncated } = cached.listing;
|
||||
return NextResponse.json({
|
||||
files: files.slice(0, MAX_FILES),
|
||||
truncated: hardTruncated || files.length > MAX_FILES,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,702 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getAllowedFileRoots,
|
||||
isExistingFilePathAllowed,
|
||||
isFilePathAllowed,
|
||||
isWindowsAbsolutePath,
|
||||
normalizeSlashes,
|
||||
} from "@/lib/file-access";
|
||||
import {
|
||||
DOCX_PREVIEW_MAX_BYTES,
|
||||
IMAGE_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
documentPreviewKind,
|
||||
getAudioMime,
|
||||
getDocumentMime,
|
||||
getFileExt,
|
||||
getImageMime,
|
||||
isAudioPath,
|
||||
isDocumentPreviewPath,
|
||||
isImagePath,
|
||||
} from "@/lib/file-types";
|
||||
import { resolveDirentIsDirectory } from "@/lib/file-dirent";
|
||||
import { isFilePathReferencedBySession } from "@/lib/session-file-references";
|
||||
import { isApiRequestAllowed } from "@/lib/request-security";
|
||||
import {
|
||||
inspectUploadTargets,
|
||||
parseUploadConflictStrategy,
|
||||
validateUploadFileNames,
|
||||
} from "@/lib/file-upload";
|
||||
import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
|
||||
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store", ".git",
|
||||
]);
|
||||
|
||||
const IGNORED_SUFFIXES = [".pyc"];
|
||||
|
||||
const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const;
|
||||
type FileRequestType = typeof FILE_REQUEST_TYPES[number];
|
||||
const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES);
|
||||
const MAX_WRITE_BODY_BYTES = TEXT_PREVIEW_MAX_BYTES;
|
||||
const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024;
|
||||
// Multipart boundaries and headers are not file bytes, but must be bounded too.
|
||||
const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
||||
|
||||
const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
||||
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
|
||||
go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
|
||||
c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
|
||||
html: "html", htm: "html", css: "css", scss: "css", less: "css",
|
||||
json: "json", jsonl: "json", yaml: "yaml", yml: "yaml",
|
||||
toml: "toml", xml: "xml", md: "markdown", mdx: "markdown",
|
||||
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
|
||||
sql: "sql", graphql: "graphql", gql: "graphql",
|
||||
dockerfile: "dockerfile", tf: "hcl", hcl: "hcl",
|
||||
env: "bash", gitignore: "bash", txt: "text",
|
||||
pdf: "pdf", docx: "word",
|
||||
};
|
||||
|
||||
function getLanguage(filePath: string): string {
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
// Special full-name matches
|
||||
if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile";
|
||||
if (base === ".env" || base.startsWith(".env.")) return "bash";
|
||||
if (base === "makefile" || base === "gnumakefile") return "makefile";
|
||||
const ext = base.split(".").pop() ?? "";
|
||||
return EXT_TO_LANGUAGE[ext] ?? "text";
|
||||
}
|
||||
|
||||
function filePathFromSegments(segments: string[]): string {
|
||||
const joined = segments.join("/");
|
||||
const slashJoined = normalizeSlashes(joined);
|
||||
if (isWindowsAbsolutePath(slashJoined)) return slashJoined;
|
||||
return "/" + joined.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function parseFileRequestType(value: string): FileRequestType | null {
|
||||
return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null;
|
||||
}
|
||||
|
||||
async function getUploadDirectory(segments: string[]): Promise<
|
||||
{ directory: string } | { response: NextResponse }
|
||||
> {
|
||||
const directory = filePathFromSegments(segments);
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(directory, allowedRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(directory);
|
||||
} catch {
|
||||
return { response: NextResponse.json({ error: "Upload directory not found" }, { status: 404 }) };
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { response: NextResponse.json({ error: "Upload target is not a directory" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
// A browsable directory can be a symlink. Resolve both sides before writes
|
||||
// so a symlink inside an allowed root cannot redirect uploads outside it.
|
||||
const realDirectory = fs.realpathSync(directory);
|
||||
const realRoots = new Set<string>();
|
||||
for (const root of allowedRoots) {
|
||||
try {
|
||||
realRoots.add(fs.realpathSync(root));
|
||||
} catch {
|
||||
// Ignore stale session roots that no longer exist.
|
||||
}
|
||||
}
|
||||
if (!isFilePathAllowed(realDirectory, realRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
return { directory: realDirectory };
|
||||
}
|
||||
|
||||
function parseUploadFileNames(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
async function getWritableFile(
|
||||
segments: string[],
|
||||
sessionId: string | null,
|
||||
): Promise<{ filePath: string } | { response: NextResponse }> {
|
||||
const filePath = filePathFromSegments(segments);
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
|
||||
const allowedBySessionReference =
|
||||
!allowedByRoot &&
|
||||
await isFilePathReferencedBySession(filePath, sessionId);
|
||||
if (!allowedByRoot && !allowedBySessionReference) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
// The target must already exist as a regular file that resolves inside an
|
||||
// allowed root. Resolving before the write prevents a symlink (possibly
|
||||
// swapped in by a race) from redirecting the payload outside the roots, and
|
||||
// the non-existing-path check above excludes files the session or current
|
||||
// roots no longer own.
|
||||
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return { response: NextResponse.json({ error: "Not found" }, { status: 404 }) };
|
||||
}
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
return { response: NextResponse.json({ error: "Not a writable file" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
return { filePath };
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
if (!isApiRequestAllowed(request)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const type = request.nextUrl.searchParams.get("type") ?? "upload";
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId");
|
||||
|
||||
if (type === "write") {
|
||||
const writable = await getWritableFile(segments, sessionId);
|
||||
if ("response" in writable) return writable.response;
|
||||
const { filePath } = writable;
|
||||
|
||||
const readonlyFile = isImagePath(filePath) || isAudioPath(filePath) || isDocumentPreviewPath(filePath);
|
||||
if (readonlyFile) {
|
||||
return NextResponse.json({ error: "This file type cannot be edited as text" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null) as { content?: unknown } | null;
|
||||
if (!body || typeof body.content !== "string") {
|
||||
return NextResponse.json({ error: "content must be a string" }, { status: 400 });
|
||||
}
|
||||
const content = body.content;
|
||||
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||
if (contentBytes > MAX_WRITE_BODY_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: "File too large to save (>256KB)" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
// Write to a temp file in the same directory then rename, so a crash or
|
||||
// an error mid-write never leaves a truncated file behind.
|
||||
const directory = path.dirname(filePath);
|
||||
const tmpPath = path.join(
|
||||
directory,
|
||||
`.pi-write-${path.basename(filePath)}-${process.pid}-${Date.now()}.tmp`,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, content, "utf-8");
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
} catch (writeError) {
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
return NextResponse.json(
|
||||
{ error: writeError instanceof Error ? writeError.message : String(writeError) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
return NextResponse.json({ size: stat.size });
|
||||
}
|
||||
|
||||
const uploadDirectory = await getUploadDirectory(segments);
|
||||
if ("response" in uploadDirectory) return uploadDirectory.response;
|
||||
const { directory } = uploadDirectory;
|
||||
|
||||
if (type === "upload-check") {
|
||||
const body = await request.json().catch(() => null) as { fileNames?: unknown } | null;
|
||||
const fileNames = parseUploadFileNames(body?.fileNames);
|
||||
if (!fileNames) {
|
||||
return NextResponse.json({ error: "fileNames must be an array of strings" }, { status: 400 });
|
||||
}
|
||||
const validationError = validateUploadFileNames(fileNames);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(inspectUploadTargets(directory, fileNames));
|
||||
}
|
||||
|
||||
if (type !== "upload") {
|
||||
return NextResponse.json({ error: "Invalid upload request type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const strategy = parseUploadConflictStrategy(request.nextUrl.searchParams.get("conflict"));
|
||||
if (!strategy) {
|
||||
return NextResponse.json({ error: "Invalid conflict strategy" }, { status: 400 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await parseFormDataWithinLimit(request, MAX_UPLOAD_REQUEST_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const files = formData.getAll("files").filter((entry): entry is File => typeof entry !== "string");
|
||||
if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES)) {
|
||||
return NextResponse.json({ error: "Each upload must be 25MB or smaller" }, { status: 413 });
|
||||
}
|
||||
if (files.reduce((total, file) => total + file.size, 0) > MAX_UPLOAD_TOTAL_BYTES) {
|
||||
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
||||
}
|
||||
const fileNames = files.map((file) => file.name);
|
||||
const validationError = validateUploadFileNames(fileNames);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
|
||||
const inspection = inspectUploadTargets(directory, fileNames);
|
||||
if (strategy === "error" && inspection.conflicts.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: "One or more files already exist",
|
||||
conflicts: inspection.conflicts,
|
||||
nonReplaceable: inspection.nonReplaceable,
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
const conflictSet = new Set(inspection.conflicts);
|
||||
const nonReplaceableSet = new Set(inspection.nonReplaceable);
|
||||
const uploaded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const destination = path.join(directory, file.name);
|
||||
if (conflictSet.has(file.name) && strategy === "skip") {
|
||||
skipped.push(file.name);
|
||||
continue;
|
||||
}
|
||||
if (conflictSet.has(file.name) && nonReplaceableSet.has(file.name)) {
|
||||
errors.push({ name: file.name, error: "Cannot replace a directory or symbolic link" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = Buffer.from(await file.arrayBuffer());
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (conflictSet.has(file.name)) {
|
||||
try {
|
||||
fs.unlinkSync(destination);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(destination, bytes, { flag: "wx" });
|
||||
uploaded.push(file.name);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ uploaded, skipped, errors },
|
||||
{ status: errors.length > 0 ? 207 : 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {
|
||||
const fileStream = fs.createReadStream(filePath, range);
|
||||
let closed = false;
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
fileStream.on("data", (chunk: Buffer) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
} catch {
|
||||
closed = true;
|
||||
fileStream.destroy();
|
||||
}
|
||||
});
|
||||
fileStream.once("end", () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// The browser may cancel media probes before the file stream ends.
|
||||
}
|
||||
});
|
||||
fileStream.once("error", (error) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.error(error);
|
||||
} catch {
|
||||
// The response was already abandoned by the client.
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
fileStream.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
||||
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(filePath: string, asDownload = false): string {
|
||||
const disposition = asDownload ? "attachment" : "inline";
|
||||
const fileName = path.basename(filePath);
|
||||
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
||||
}
|
||||
|
||||
function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response {
|
||||
const headers = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-cache",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": getContentDisposition(filePath, asDownload),
|
||||
};
|
||||
|
||||
if (!rangeHeader) {
|
||||
return new Response(createFileBodyStream(filePath), {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Length": String(stat.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
||||
if (!match) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let start = match[1] ? Number(match[1]) : 0;
|
||||
let end = match[2] ? Number(match[2]) : stat.size - 1;
|
||||
if (!match[1] && match[2]) {
|
||||
const suffixLength = Number(match[2]);
|
||||
start = Math.max(stat.size - suffixLength, 0);
|
||||
end = stat.size - 1;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= stat.size) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
end = Math.min(end, stat.size - 1);
|
||||
const chunkSize = end - start + 1;
|
||||
return new Response(createFileBodyStream(filePath, { start, end }), {
|
||||
status: 206,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Length": String(chunkSize),
|
||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
html, body { margin: 0; min-height: 100%; background: #eef1f5; color: #171717; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 28px; }
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
max-width: 840px;
|
||||
min-height: calc(100vh - 56px);
|
||||
margin: 0 auto;
|
||||
padding: 56px 64px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
.file-title {
|
||||
margin: 0 0 28px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #6b7280;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.1em 0 0.45em; color: #111827; }
|
||||
p { margin: 0.65em 0; line-height: 1.7; }
|
||||
table { border-collapse: collapse; max-width: 100%; margin: 1em 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 6px 9px; vertical-align: top; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
a { color: #2563eb; }
|
||||
@media (max-width: 720px) {
|
||||
body { padding: 0; background: #fff; }
|
||||
main { min-height: 100vh; padding: 28px 22px; box-shadow: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="file-title">${escapeHtml(fileName)}</div>
|
||||
${bodyHtml}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const filePath = filePathFromSegments(segments);
|
||||
const rawType = request.nextUrl.searchParams.get("type") ?? "list";
|
||||
const type = parseFileRequestType(rawType);
|
||||
if (!type) {
|
||||
return NextResponse.json({ error: "Invalid file request type" }, { status: 400 });
|
||||
}
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId");
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
|
||||
const allowedBySessionReference =
|
||||
!allowedByRoot &&
|
||||
type !== "list" &&
|
||||
await isFilePathReferencedBySession(filePath, sessionId);
|
||||
if (!allowedByRoot && !allowedBySessionReference) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(filePath);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (type === "read") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const imageMime = getImageMime(filePath);
|
||||
if (imageMime) {
|
||||
if (stat.size > IMAGE_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "Image too large (>10MB)" }, { status: 413 });
|
||||
}
|
||||
return streamFile(filePath, stat, imageMime, request.headers.get("range"));
|
||||
}
|
||||
const audioMime = getAudioMime(filePath);
|
||||
if (audioMime) {
|
||||
return streamFile(filePath, stat, audioMime, request.headers.get("range"));
|
||||
}
|
||||
const documentMime = getDocumentMime(filePath);
|
||||
if (documentMime) {
|
||||
return streamFile(filePath, stat, documentMime, request.headers.get("range"));
|
||||
}
|
||||
if (stat.size > TEXT_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 });
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const language = getLanguage(filePath);
|
||||
return NextResponse.json({ content, language, size: stat.size });
|
||||
}
|
||||
|
||||
if (type === "download") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const mime = getImageMime(filePath) || getAudioMime(filePath) || getDocumentMime(filePath) || "application/octet-stream";
|
||||
return streamFile(filePath, stat, mime, request.headers.get("range"), true);
|
||||
}
|
||||
|
||||
if (type === "meta") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const imageMime = getImageMime(filePath);
|
||||
const audioMime = getAudioMime(filePath);
|
||||
const documentMime = getDocumentMime(filePath);
|
||||
return NextResponse.json({
|
||||
size: stat.size,
|
||||
language: getLanguage(filePath),
|
||||
mime: imageMime || audioMime || documentMime || "text/plain",
|
||||
previewKind: documentPreviewKind(filePath),
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "preview") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
if (getFileExt(filePath) !== "docx") {
|
||||
return NextResponse.json({ error: "Preview not available for this file type" }, { status: 400 });
|
||||
}
|
||||
if (stat.size > DOCX_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
const mammoth = await import("mammoth");
|
||||
const result = await mammoth.convertToHtml(
|
||||
{ path: filePath },
|
||||
{
|
||||
externalFileAccess: false,
|
||||
convertImage: mammoth.images.dataUri,
|
||||
}
|
||||
);
|
||||
const html = wrapDocxPreviewHtml(result.value, path.basename(filePath));
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "watch") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
let watcher: fs.FSWatcher | null = null;
|
||||
let lastMtimeMs = stat.mtimeMs;
|
||||
let lastSize = stat.size;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = (eventName: string, data: Record<string, unknown>) => {
|
||||
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(payload));
|
||||
} catch {
|
||||
// client disconnected
|
||||
}
|
||||
};
|
||||
// Send initial ping so client knows connection is live
|
||||
send("connected", { filePath });
|
||||
try {
|
||||
watcher = fs.watch(filePath, () => {
|
||||
try {
|
||||
const s = fs.statSync(filePath);
|
||||
// Some platforms emit watch events for file reads/attribute
|
||||
// access. Ignore those or the client's refresh read loops.
|
||||
if (s.mtimeMs === lastMtimeMs && s.size === lastSize) return;
|
||||
lastMtimeMs = s.mtimeMs;
|
||||
lastSize = s.size;
|
||||
send("change", { mtime: s.mtime.toISOString(), size: s.size });
|
||||
} catch {
|
||||
send("change", { mtime: new Date().toISOString(), size: 0 });
|
||||
}
|
||||
});
|
||||
watcher.on("error", () => {
|
||||
try { controller.close(); } catch { /* ignore */ }
|
||||
});
|
||||
} catch {
|
||||
send("error", { message: "Failed to watch file" });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
try { watcher?.close(); } catch { /* ignore */ }
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// type === "list"
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Avoid per-entry stat calls for normal files and directories. Symlinks and
|
||||
// filesystems without directory type information use the stat fallback.
|
||||
const dirents = fs.readdirSync(filePath, { withFileTypes: true });
|
||||
const entries = dirents
|
||||
.filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s)))
|
||||
.flatMap((d) => {
|
||||
const isDir = resolveDirentIsDirectory(d, path.join(filePath, d.name));
|
||||
return isDir === null
|
||||
? []
|
||||
: [{ name: d.name, isDir, size: 0, modified: "" }];
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Dirs first, then files, both alphabetically
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entries, path: filePath });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access";
|
||||
import { getGitFileDiff } from "@/lib/git-changes";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
const filePath = request.nextUrl.searchParams.get("path")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
if (!filePath || (!filePath.startsWith("/") && !isWindowsAbsolutePath(filePath))) {
|
||||
return NextResponse.json({ error: "path must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots) || !isFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
// The cwd must resolve inside an allowed root. The file itself may no
|
||||
// longer exist when Git reports it as deleted; getGitFileDiff verifies
|
||||
// that the requested path belongs to this repository and its status.
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await getGitFileDiff(cwd, filePath));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import fs from "fs";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access";
|
||||
import { getGitStatus } from "@/lib/git-changes";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(cwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await getGitStatus(cwd));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { homedir } from "os";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ home: homedir() });
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getMcpPath(): string {
|
||||
return join(getAgentDir(), "mcp.json");
|
||||
}
|
||||
|
||||
interface McpConfigFile {
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
settings?: Record<string, unknown>;
|
||||
imports?: string[];
|
||||
}
|
||||
|
||||
function readMcpJson(): McpConfigFile {
|
||||
const path = getMcpPath();
|
||||
if (!existsSync(path)) return { mcpServers: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as McpConfigFile;
|
||||
if (!parsed || typeof parsed !== "object" || typeof parsed.mcpServers !== "object") {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeMcpJson(data: McpConfigFile): void {
|
||||
const path = getMcpPath();
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ config: readMcpJson(), path: getMcpPath() });
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as McpConfigFile;
|
||||
if (!body || typeof body !== "object" || typeof body.mcpServers !== "object" || body.mcpServers === null) {
|
||||
return NextResponse.json({ error: "mcpServers must be an object" }, { status: 400 });
|
||||
}
|
||||
// 只保留已知顶层字段,避免写入无关键
|
||||
const out: McpConfigFile = { mcpServers: body.mcpServers };
|
||||
if (body.settings && typeof body.settings === "object") out.settings = body.settings;
|
||||
if (Array.isArray(body.imports)) out.imports = body.imports;
|
||||
writeMcpJson(out);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
flattenModelsDevCatalog,
|
||||
recommendModelCatalogPreset,
|
||||
searchModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
} from "@/lib/model-catalog";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MODELS_DEV_URL = "https://models.dev/api.json";
|
||||
const CATALOG_TTL_MS = 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface CatalogCache {
|
||||
entries: ModelCatalogEntry[];
|
||||
expiresAt: number;
|
||||
inFlight?: Promise<ModelCatalogEntry[]>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __piModelsDevCatalogCache: CatalogCache | undefined;
|
||||
}
|
||||
|
||||
function getCache(): CatalogCache {
|
||||
return globalThis.__piModelsDevCatalogCache ??= { entries: [], expiresAt: 0 };
|
||||
}
|
||||
|
||||
async function fetchCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
const response = await fetch(MODELS_DEV_URL, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`);
|
||||
const entries = flattenModelsDevCatalog(await response.json());
|
||||
if (entries.length === 0) throw new Error("models.dev returned an empty catalog");
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
const cache = getCache();
|
||||
if (cache.entries.length > 0 && cache.expiresAt > Date.now()) return cache.entries;
|
||||
if (!cache.inFlight) {
|
||||
cache.inFlight = fetchCatalog().then((entries) => {
|
||||
cache.entries = entries;
|
||||
cache.expiresAt = Date.now() + CATALOG_TTL_MS;
|
||||
return entries;
|
||||
}).finally(() => {
|
||||
cache.inFlight = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await cache.inFlight;
|
||||
} catch (error) {
|
||||
if (cache.entries.length > 0) return cache.entries;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const query = (searchParams.get("q") ?? "").slice(0, 120);
|
||||
const provider = (searchParams.get("provider") ?? "").slice(0, 120);
|
||||
const baseUrl = (searchParams.get("baseUrl") ?? "").slice(0, 500);
|
||||
const parsedLimit = Number.parseInt(searchParams.get("limit") ?? "50", 10);
|
||||
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
|
||||
|
||||
try {
|
||||
const entries = await loadCatalog();
|
||||
const models = searchModelCatalog(entries, query, provider, limit);
|
||||
const recommendation = recommendModelCatalogPreset(entries, query, provider, baseUrl);
|
||||
return NextResponse.json({ models, recommendation, source: MODELS_DEV_URL });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveModelDiscoveryAuth } from "@/lib/model-discovery-auth";
|
||||
import { buildModelsListUrl, parseDiscoveredModels } from "@/lib/model-discovery";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DISCOVERY_TIMEOUT_MS = 20_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasHeader(headers: Headers, name: string): boolean {
|
||||
return headers.has(name);
|
||||
}
|
||||
|
||||
function buildHeaders(api: string, apiKey: string | undefined, configured: Record<string, string>): Headers {
|
||||
const headers = new Headers(configured);
|
||||
if (!hasHeader(headers, "accept")) headers.set("Accept", "application/json");
|
||||
if (!apiKey) return headers;
|
||||
|
||||
if (api === "anthropic-messages") {
|
||||
if (!hasHeader(headers, "x-api-key")) headers.set("x-api-key", apiKey);
|
||||
if (!hasHeader(headers, "anthropic-version")) headers.set("anthropic-version", "2023-06-01");
|
||||
} else if (api === "google-generative-ai") {
|
||||
if (!hasHeader(headers, "x-goog-api-key")) headers.set("x-goog-api-key", apiKey);
|
||||
} else if (!hasHeader(headers, "authorization")) {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { providerName?: unknown; provider?: unknown };
|
||||
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
|
||||
if (!providerName) return NextResponse.json({ error: "providerName is required" }, { status: 400 });
|
||||
if (!isRecord(body.provider)) return NextResponse.json({ error: "provider is required" }, { status: 400 });
|
||||
|
||||
const baseUrl = typeof body.provider.baseUrl === "string" ? body.provider.baseUrl.trim() : "";
|
||||
if (!baseUrl) return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
|
||||
const api = typeof body.provider.api === "string" && body.provider.api
|
||||
? body.provider.api
|
||||
: "openai-completions";
|
||||
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = buildModelsListUrl(baseUrl, api);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Base URL is invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await resolveModelDiscoveryAuth(providerName, body.provider);
|
||||
if (typeof body.provider.apiKey === "string" && body.provider.apiKey.trim() && !auth.apiKey) {
|
||||
return NextResponse.json({ error: `No API key found for "${providerName}"` }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
cache: "no-store",
|
||||
headers: buildHeaders(api, auth.apiKey, auth.headers),
|
||||
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({
|
||||
error: responseText.slice(0, 500) || `Upstream returned HTTP ${response.status}`,
|
||||
status: response.status,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(responseText);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Upstream model list was not valid JSON" }, { status: 502 });
|
||||
}
|
||||
const models = parseDiscoveredModels(payload);
|
||||
if (models.length === 0) {
|
||||
return NextResponse.json({ error: "No models found in the upstream response" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ models, endpoint: endpoint.toString() });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = error instanceof DOMException && error.name === "TimeoutError" ? 504 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getModelsPath(): string {
|
||||
return join(getAgentDir(), "models.json");
|
||||
}
|
||||
|
||||
function readModelsJson(): Record<string, unknown> {
|
||||
const path = getModelsPath();
|
||||
if (!existsSync(path)) return { providers: {} };
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { providers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeModelsJson(data: Record<string, unknown>): void {
|
||||
const path = getModelsPath();
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(readModelsJson());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
writeModelsJson(body);
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { completeSimple, type AssistantMessage } from "@earendil-works/pi-ai/compat";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function getAssistantText(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!isApiRequestAllowed(req)) {
|
||||
return NextResponse.json({ ok: false, error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
if (!hasJsonContentType(req)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Content-Type must be application/json" },
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
try {
|
||||
const body = await req.json() as { providerName?: unknown; provider?: unknown; model?: unknown };
|
||||
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
|
||||
if (!providerName) return NextResponse.json({ ok: false, error: "providerName is required" }, { status: 400 });
|
||||
if (!isRecord(body.provider)) return NextResponse.json({ ok: false, error: "provider is required" }, { status: 400 });
|
||||
if (!isRecord(body.model)) return NextResponse.json({ ok: false, error: "model is required" }, { status: 400 });
|
||||
|
||||
const modelId = typeof body.model.id === "string" ? body.model.id.trim() : "";
|
||||
if (!modelId) return NextResponse.json({ ok: false, error: "Model ID is required" }, { status: 400 });
|
||||
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-test-"));
|
||||
const modelsPath = join(tempDir, "models.json");
|
||||
writeFileSync(modelsPath, JSON.stringify({
|
||||
providers: {
|
||||
[providerName]: {
|
||||
...body.provider,
|
||||
models: [{ ...body.model, id: modelId }],
|
||||
},
|
||||
},
|
||||
}, null, 2), "utf8");
|
||||
|
||||
const modelRuntime = await ModelRuntime.create({ modelsPath });
|
||||
const loadError = modelRuntime.getError();
|
||||
if (loadError) return NextResponse.json({ ok: false, error: loadError });
|
||||
|
||||
const model = modelRuntime.getModel(providerName, modelId);
|
||||
if (!model) return NextResponse.json({ ok: false, error: `Model not found: ${providerName}/${modelId}` });
|
||||
|
||||
const resolved = await modelRuntime.getAuth(model);
|
||||
if (!resolved?.auth.apiKey) {
|
||||
return NextResponse.json({ ok: false, error: `No API key found for "${providerName}"` });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
||||
let status: number | undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const message = await completeSimple(model, {
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: "Reply with OK only.",
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
}, {
|
||||
apiKey: resolved.auth.apiKey,
|
||||
headers: resolved.auth.headers,
|
||||
maxTokens: 16,
|
||||
timeoutMs: TEST_TIMEOUT_MS,
|
||||
maxRetries: 0,
|
||||
cacheRetention: "none",
|
||||
signal: controller.signal,
|
||||
onResponse: (response) => { status = response.status; },
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: message.errorMessage ?? (controller.signal.aborted ? "Test timed out" : "Model returned an error"),
|
||||
latencyMs,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
latencyMs,
|
||||
status,
|
||||
responseText: getAssistantText(message).slice(0, 300),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ ok: false, error: errorMessage(error) }, { status: 500 });
|
||||
} finally {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { stat } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { createAgentSessionServices, getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||
import { loadModelsWithCache, withModelRuntimeError, type ModelsData } from "@/lib/models-cache";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { projectTrustReloadOptions } from "@/lib/project-trust";
|
||||
import { createAppSettingsManager, getAppResourceLoaderOptions } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function compareModelEntries(
|
||||
a: { id: string; name: string; provider: string },
|
||||
b: { id: string; name: string; provider: string }
|
||||
): number {
|
||||
return modelNameCollator.compare(a.name || a.id, b.name || b.id)
|
||||
|| modelNameCollator.compare(a.provider, b.provider)
|
||||
|| modelNameCollator.compare(a.id, b.id);
|
||||
}
|
||||
|
||||
const THINKING_SUFFIXES = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
|
||||
function stripThinkingSuffix(modelRef: string): string {
|
||||
const trimmed = modelRef.trim();
|
||||
const colonIndex = trimmed.lastIndexOf(":");
|
||||
if (colonIndex === -1) return trimmed;
|
||||
const suffix = trimmed.substring(colonIndex + 1);
|
||||
return THINKING_SUFFIXES.has(suffix) ? trimmed.substring(0, colonIndex) : trimmed;
|
||||
}
|
||||
|
||||
function filterByExactEnabledModels<T extends { id: string; provider: string }>(
|
||||
available: readonly T[],
|
||||
enabledModels: string[] | undefined,
|
||||
): readonly T[] {
|
||||
if (!enabledModels || enabledModels.length === 0) return available;
|
||||
|
||||
const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean));
|
||||
const visible = available.filter((m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id));
|
||||
return visible.length > 0 ? visible : available;
|
||||
}
|
||||
|
||||
async function loadModels(cwd: string): Promise<ModelsData> {
|
||||
const nameMap = new Map<string, string>();
|
||||
let modelList: { id: string; name: string; provider: string }[] = [];
|
||||
let defaultModel: { provider: string; modelId: string } | null = null;
|
||||
const thinkingLevels: Record<string, string[]> = {};
|
||||
const thinkingLevelMaps: Record<string, Record<string, string | null>> = {};
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
// Gate untrusted project extensions: enumerating models still imports and
|
||||
// runs a repository's .pi/extensions factories, so honor project trust here
|
||||
// too (see lib/project-trust.ts, #236).
|
||||
const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir);
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: createAppSettingsManager(cwd, agentDir),
|
||||
resourceLoaderOptions: getAppResourceLoaderOptions(),
|
||||
...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}),
|
||||
});
|
||||
const available = await services.modelRuntime.getAvailable();
|
||||
const modelError = services.modelRuntime.getError();
|
||||
const settings: SettingsManager = services.settingsManager;
|
||||
const enabledModels = settings.getEnabledModels();
|
||||
const visible = filterByExactEnabledModels(available, enabledModels);
|
||||
modelList = visible.map((m: { id: string; name: string; provider: string }) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: m.provider,
|
||||
})).sort(compareModelEntries);
|
||||
for (const m of visible) {
|
||||
const key = `${m.provider}:${m.id}`;
|
||||
nameMap.set(key, m.name);
|
||||
thinkingLevels[key] = getSupportedThinkingLevels(m);
|
||||
if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap;
|
||||
}
|
||||
|
||||
const provider = settings.getDefaultProvider();
|
||||
const modelId = settings.getDefaultModel();
|
||||
if (provider && modelId && visible.some((m) => m.provider === provider && m.id === modelId)) {
|
||||
defaultModel = { provider, modelId };
|
||||
}
|
||||
|
||||
return withModelRuntimeError(
|
||||
{ models: Object.fromEntries(nameMap), modelList, defaultModel, thinkingLevels, thinkingLevelMaps },
|
||||
modelError,
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_MODELS: ModelsData = {
|
||||
models: {},
|
||||
modelList: [],
|
||||
defaultModel: null,
|
||||
thinkingLevels: {},
|
||||
thinkingLevelMaps: {},
|
||||
};
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const requestedCwd = new URL(req.url).searchParams.get("cwd") || process.cwd();
|
||||
const cwd = resolve(requestedCwd);
|
||||
|
||||
let cwdStat;
|
||||
try {
|
||||
cwdStat = await stat(cwd);
|
||||
} catch {
|
||||
return Response.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
if (!cwdStat.isDirectory()) {
|
||||
return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
return Response.json(await loadModelsWithCache(cwd, () => loadModels(cwd)));
|
||||
} catch {
|
||||
return Response.json(EMPTY_MODELS);
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { basename, dirname, extname, join, relative, resolve } from "path";
|
||||
import {
|
||||
DefaultPackageManager,
|
||||
getAgentDir,
|
||||
SettingsManager,
|
||||
type PackageSource,
|
||||
type ResolvedPaths,
|
||||
type ResolvedResource,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
import { getProjectTrustStatus } from "@/lib/project-trust";
|
||||
import { createAppSettingsManager, isManagedPath, isManagedRuntime } from "@/lib/app-runtime";
|
||||
import type {
|
||||
PluginDiagnostic,
|
||||
PluginPackageInfo,
|
||||
PluginResourceCounts,
|
||||
PluginResourceInfo,
|
||||
PluginResourceKind,
|
||||
PluginScope,
|
||||
PluginsResponse,
|
||||
} from "@/lib/api-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PluginAction = "install" | "remove" | "update" | "disable" | "enable";
|
||||
|
||||
function emptyCounts(): PluginResourceCounts {
|
||||
return { extensions: 0, skills: 0, prompts: 0, themes: 0 };
|
||||
}
|
||||
|
||||
function toPluginScope(scope: string): PluginScope {
|
||||
return scope === "project" ? "project" : "global";
|
||||
}
|
||||
|
||||
function keyFor(source: string, scope: PluginScope): string {
|
||||
return `${scope}\0${source}`;
|
||||
}
|
||||
|
||||
function getPackageSource(entry: PackageSource): string {
|
||||
return typeof entry === "string" ? entry : entry.source;
|
||||
}
|
||||
|
||||
function isDisabledPackage(entry: PackageSource): boolean {
|
||||
if (typeof entry === "string") return false;
|
||||
return (
|
||||
Array.isArray(entry.extensions) && entry.extensions.length === 0 &&
|
||||
Array.isArray(entry.skills) && entry.skills.length === 0 &&
|
||||
Array.isArray(entry.prompts) && entry.prompts.length === 0 &&
|
||||
Array.isArray(entry.themes) && entry.themes.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
function getDisabledPackages(settingsManager: SettingsManager): Map<string, boolean> {
|
||||
const disabled = new Map<string, boolean>();
|
||||
for (const entry of settingsManager.getGlobalSettings().packages ?? []) {
|
||||
disabled.set(keyFor(getPackageSource(entry), "global"), isDisabledPackage(entry));
|
||||
}
|
||||
for (const entry of settingsManager.getProjectSettings().packages ?? []) {
|
||||
disabled.set(keyFor(getPackageSource(entry), "project"), isDisabledPackage(entry));
|
||||
}
|
||||
return disabled;
|
||||
}
|
||||
|
||||
function setPackageDisabled(
|
||||
settingsManager: SettingsManager,
|
||||
source: string,
|
||||
scope: PluginScope,
|
||||
disabled: boolean,
|
||||
): boolean {
|
||||
const current = scope === "project"
|
||||
? settingsManager.getProjectSettings().packages ?? []
|
||||
: settingsManager.getGlobalSettings().packages ?? [];
|
||||
let changed = false;
|
||||
const next = current.map((entry): PackageSource => {
|
||||
if (getPackageSource(entry) !== source) return entry;
|
||||
changed = true;
|
||||
if (disabled) {
|
||||
return {
|
||||
...(typeof entry === "string" ? { source: entry } : entry),
|
||||
extensions: [],
|
||||
skills: [],
|
||||
prompts: [],
|
||||
themes: [],
|
||||
};
|
||||
}
|
||||
return getPackageSource(entry);
|
||||
});
|
||||
if (!changed) return false;
|
||||
if (scope === "project") settingsManager.setProjectPackages(next);
|
||||
else settingsManager.setPackages(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
function addCount(counts: PluginResourceCounts, kind: keyof PluginResourceCounts): void {
|
||||
counts[kind] += 1;
|
||||
}
|
||||
|
||||
function getResourceName(path: string, kind: PluginResourceKind): string {
|
||||
const file = basename(path);
|
||||
const ext = extname(file);
|
||||
if (kind === "skill" && file.toLowerCase() === "skill.md") return basename(dirname(path));
|
||||
if ((kind === "extension" || kind === "theme" || kind === "prompt") && ext) {
|
||||
if (kind === "extension" && /^index\.(ts|js)$/.test(file)) return basename(dirname(path));
|
||||
return file.slice(0, -ext.length);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function getRelativePath(resource: ResolvedResource): string {
|
||||
const baseDir = resource.metadata.baseDir;
|
||||
if (!baseDir) return resource.path;
|
||||
const rel = relative(baseDir, resource.path);
|
||||
return rel && !rel.startsWith("..") ? rel : resource.path;
|
||||
}
|
||||
|
||||
function getConfiguredVersion(source: string): string | undefined {
|
||||
const npmSpec = source.startsWith("npm:") ? source.slice(4) : undefined;
|
||||
if (npmSpec) {
|
||||
const lastAt = npmSpec.lastIndexOf("@");
|
||||
const packageNameEnd = npmSpec.startsWith("@") ? npmSpec.indexOf("/", 1) : 0;
|
||||
if (lastAt > packageNameEnd) return npmSpec.slice(lastAt + 1) || undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (source.startsWith("git:") || /^[a-z]+:\/\//.test(source)) {
|
||||
const lastAt = source.lastIndexOf("@");
|
||||
const lastSlash = source.lastIndexOf("/");
|
||||
const lastColon = source.lastIndexOf(":");
|
||||
if (lastAt > Math.max(lastSlash, lastColon)) return source.slice(lastAt + 1) || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPackageMetadata(installedPath?: string): { packageName?: string; version?: string } {
|
||||
if (!installedPath) return {};
|
||||
try {
|
||||
const stats = statSync(installedPath);
|
||||
const packageJsonPath = stats.isDirectory()
|
||||
? join(installedPath, "package.json")
|
||||
: join(dirname(installedPath), "package.json");
|
||||
if (!existsSync(packageJsonPath)) return {};
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: unknown;
|
||||
version?: unknown;
|
||||
};
|
||||
return {
|
||||
packageName: typeof parsed.name === "string" ? parsed.name : undefined,
|
||||
version: typeof parsed.version === "string" ? parsed.version : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function collectResource(
|
||||
resource: ResolvedResource,
|
||||
kind: keyof PluginResourceCounts,
|
||||
countsByPackage: Map<string, PluginResourceCounts>,
|
||||
resourcesByPackage: Map<string, PluginResourceInfo[]>,
|
||||
totals: PluginResourceCounts,
|
||||
): void {
|
||||
if (!isManagedPath(resource.path)) return;
|
||||
if (!resource.enabled || resource.metadata.origin !== "package") return;
|
||||
const source = resource.metadata.source;
|
||||
const scope = toPluginScope(resource.metadata.scope);
|
||||
const key = keyFor(source, scope);
|
||||
const counts = countsByPackage.get(key) ?? emptyCounts();
|
||||
addCount(counts, kind);
|
||||
addCount(totals, kind);
|
||||
countsByPackage.set(key, counts);
|
||||
const resources = resourcesByPackage.get(key) ?? [];
|
||||
const resourceKind = kind === "extensions"
|
||||
? "extension"
|
||||
: kind === "skills"
|
||||
? "skill"
|
||||
: kind === "prompts"
|
||||
? "prompt"
|
||||
: "theme";
|
||||
resources.push({
|
||||
kind: resourceKind,
|
||||
name: getResourceName(resource.path, resourceKind),
|
||||
path: resource.path,
|
||||
relativePath: getRelativePath(resource),
|
||||
});
|
||||
resourcesByPackage.set(key, resources);
|
||||
}
|
||||
|
||||
function collectResources(paths: ResolvedPaths): {
|
||||
countsByPackage: Map<string, PluginResourceCounts>;
|
||||
resourcesByPackage: Map<string, PluginResourceInfo[]>;
|
||||
totals: PluginResourceCounts;
|
||||
} {
|
||||
const countsByPackage = new Map<string, PluginResourceCounts>();
|
||||
const resourcesByPackage = new Map<string, PluginResourceInfo[]>();
|
||||
const totals = emptyCounts();
|
||||
for (const resource of paths.extensions) collectResource(resource, "extensions", countsByPackage, resourcesByPackage, totals);
|
||||
for (const resource of paths.skills) collectResource(resource, "skills", countsByPackage, resourcesByPackage, totals);
|
||||
for (const resource of paths.prompts) collectResource(resource, "prompts", countsByPackage, resourcesByPackage, totals);
|
||||
for (const resource of paths.themes) collectResource(resource, "themes", countsByPackage, resourcesByPackage, totals);
|
||||
return { countsByPackage, resourcesByPackage, totals };
|
||||
}
|
||||
|
||||
async function readPlugins(cwd: string): Promise<PluginsResponse> {
|
||||
const agentDir = getAgentDir();
|
||||
const projectTrust = getProjectTrustStatus(cwd, agentDir);
|
||||
const settingsManager = createAppSettingsManager(cwd, agentDir, projectTrust.trusted);
|
||||
const packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
|
||||
const diagnostics: PluginDiagnostic[] = [];
|
||||
let countsByPackage = new Map<string, PluginResourceCounts>();
|
||||
let resourcesByPackage = new Map<string, PluginResourceInfo[]>();
|
||||
let totals = emptyCounts();
|
||||
const disabledByPackage = getDisabledPackages(settingsManager);
|
||||
|
||||
try {
|
||||
const resolved = await packageManager.resolve(async (source) => {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
source,
|
||||
message: "Package is configured but not installed yet.",
|
||||
});
|
||||
return "skip";
|
||||
});
|
||||
({ countsByPackage, resourcesByPackage, totals } = collectResources(resolved));
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
const packages = packageManager.listConfiguredPackages().map((pkg) => {
|
||||
const scope = toPluginScope(pkg.scope);
|
||||
const key = keyFor(pkg.source, scope);
|
||||
const disabled = disabledByPackage.get(key) ?? false;
|
||||
const counts = countsByPackage.get(key) ?? emptyCounts();
|
||||
const resources = resourcesByPackage.get(key) ?? [];
|
||||
const resourceCount = counts.extensions + counts.skills + counts.prompts + counts.themes;
|
||||
const packageMetadata = readPackageMetadata(pkg.installedPath);
|
||||
if (!pkg.installedPath) {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
source: pkg.source,
|
||||
message: "Configured package path was not found.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
source: pkg.source,
|
||||
scope,
|
||||
filtered: pkg.filtered,
|
||||
disabled,
|
||||
installedPath: pkg.installedPath,
|
||||
packageName: packageMetadata.packageName,
|
||||
version: packageMetadata.version,
|
||||
configuredVersion: getConfiguredVersion(pkg.source),
|
||||
counts,
|
||||
resources,
|
||||
status: disabled ? "disabled" : resourceCount > 0 ? "loaded" : pkg.installedPath ? "installed" : "missing",
|
||||
} satisfies PluginPackageInfo;
|
||||
});
|
||||
|
||||
return {
|
||||
packages,
|
||||
totals,
|
||||
diagnostics,
|
||||
projectResourcesLoaded: projectTrust.trusted,
|
||||
};
|
||||
}
|
||||
|
||||
function readScope(scope: unknown): PluginScope {
|
||||
if (isManagedRuntime()) return "global";
|
||||
return scope === "project" ? "project" : "global";
|
||||
}
|
||||
|
||||
function isRemotePackageSource(source: string): boolean {
|
||||
return source.startsWith("npm:") || source.startsWith("git:") || /^[a-z]+:\/\//i.test(source);
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const cwd = searchParams.get("cwd");
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(await readPlugins(cwd));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/plugins body: { action, source?, scope?, cwd }
|
||||
export async function POST(req: Request) {
|
||||
if (!isApiRequestAllowed(req)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
if (!hasJsonContentType(req)) {
|
||||
return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
action?: PluginAction;
|
||||
source?: string;
|
||||
scope?: PluginScope;
|
||||
cwd?: string;
|
||||
};
|
||||
if (!body.cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
if (!body.action) return NextResponse.json({ error: "action required" }, { status: 400 });
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(body.cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const projectTrust = getProjectTrustStatus(body.cwd, agentDir);
|
||||
const settingsManager = createAppSettingsManager(body.cwd, agentDir, projectTrust.trusted);
|
||||
const scope = readScope(body.scope);
|
||||
if (scope === "project" && !projectTrust.trusted) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project resources must be trusted before modifying project plugins" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const packageManager = new DefaultPackageManager({
|
||||
cwd: body.cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
const source = body.source?.trim();
|
||||
const local = scope === "project";
|
||||
|
||||
if (
|
||||
source &&
|
||||
isManagedRuntime() &&
|
||||
!isRemotePackageSource(source) &&
|
||||
!isManagedPath(resolve(body.cwd, source))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Local plugins must be stored inside the integrated application directory" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (body.action === "install") {
|
||||
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
||||
await packageManager.installAndPersist(source, { local });
|
||||
} else if (body.action === "remove") {
|
||||
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
||||
await packageManager.removeAndPersist(source, { local });
|
||||
} else if (body.action === "update") {
|
||||
await packageManager.update(source);
|
||||
} else if (body.action === "disable") {
|
||||
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
||||
setPackageDisabled(settingsManager, source, scope, true);
|
||||
await settingsManager.flush();
|
||||
} else if (body.action === "enable") {
|
||||
if (!source) return NextResponse.json({ error: "source required" }, { status: 400 });
|
||||
setPackageDisabled(settingsManager, source, scope, false);
|
||||
await settingsManager.flush();
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unsupported action: ${body.action}` }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await readPlugins(body.cwd));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { stat } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
import { getProjectTrustStatus, trustProject } from "@/lib/project-trust";
|
||||
import { destroyRpcSessionsForCwd, hasBusyRpcSessionForCwd } from "@/lib/rpc-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function validateCwd(value: unknown): Promise<
|
||||
{ cwd: string } | { response: NextResponse }
|
||||
> {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return { response: NextResponse.json({ error: "cwd required" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
const cwd = resolve(value);
|
||||
try {
|
||||
if (!(await stat(cwd)).isDirectory()) {
|
||||
return { response: NextResponse.json({ error: "cwd must be a directory" }, { status: 400 }) };
|
||||
}
|
||||
} catch {
|
||||
return { response: NextResponse.json({ error: "Directory does not exist" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const result = await validateCwd(new URL(req.url).searchParams.get("cwd"));
|
||||
if ("response" in result) return result.response;
|
||||
return NextResponse.json(getProjectTrustStatus(result.cwd, getAgentDir()));
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: unknown };
|
||||
const result = await validateCwd(body.cwd);
|
||||
if ("response" in result) return result.response;
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const current = getProjectTrustStatus(result.cwd, agentDir);
|
||||
if (!current.requiresTrust) {
|
||||
return NextResponse.json({ error: "This project has no resources that require trust" }, { status: 409 });
|
||||
}
|
||||
if (hasBusyRpcSessionForCwd(result.cwd)) {
|
||||
return NextResponse.json({ error: "Wait for the active session to finish before trusting this project" }, { status: 409 });
|
||||
}
|
||||
|
||||
const status = trustProject(result.cwd, agentDir);
|
||||
invalidateModelsCache();
|
||||
destroyRpcSessionsForCwd(result.cwd);
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
getManagedRuntimePaths,
|
||||
isManagedRuntime,
|
||||
} from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
if (!isManagedRuntime()) {
|
||||
return Response.json({ managed: false, agentDir: getAgentDir() });
|
||||
}
|
||||
|
||||
const paths = getManagedRuntimePaths();
|
||||
return Response.json({
|
||||
managed: true,
|
||||
appRoot: paths.appRoot,
|
||||
dataDir: paths.dataDir,
|
||||
agentDir: paths.agentDir,
|
||||
resourcesDir: paths.resourcesDir,
|
||||
skillRoots: paths.managedSkillRoots,
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
import { generateSessionTitle } from "@/lib/session-title";
|
||||
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
|
||||
import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
const existing = getRpcSession(id);
|
||||
const { session } = existing?.isAlive()
|
||||
? { session: existing }
|
||||
: await startRpcSession(id, filePath, cwd);
|
||||
|
||||
// globalThis keeps wrappers alive across dev hot reloads; older instances
|
||||
// may predate waitUntilReady(), but those have already completed startup.
|
||||
await session.waitUntilReady?.();
|
||||
const result = await generateSessionTitle(session.inner as unknown as AgentSession);
|
||||
|
||||
if (!session.isAlive()) {
|
||||
return NextResponse.json(
|
||||
{ error: "The session was closed while its title was being generated. Please try again." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
session.inner.setSessionName(result.title);
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ title: result.title, usage: result.usage ?? null });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import { resolveSessionPath, buildSessionContext } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const url = new URL(req.url);
|
||||
const leafId = url.searchParams.get("leafId") ?? undefined;
|
||||
const deferThinking = url.searchParams.has("deferThinking");
|
||||
const deferToolResultImages = url.searchParams.has("deferMedia");
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sm = SessionManager.open(filePath);
|
||||
const context = buildSessionContext(sm.getEntries() as never, leafId, {
|
||||
deferThinking,
|
||||
deferToolResultImages,
|
||||
});
|
||||
|
||||
return NextResponse.json({ context });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionEntries, resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string; entryId: string }> },
|
||||
) {
|
||||
const { id, entryId } = await params;
|
||||
const blockIndexParam = new URL(req.url).searchParams.get("blockIndex");
|
||||
const blockIndex = blockIndexParam === null ? Number.NaN : Number(blockIndexParam);
|
||||
if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) {
|
||||
return NextResponse.json({ error: "Valid blockIndex is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
|
||||
// SessionManager-backed parsing preserves the SDK's malformed-line tolerance.
|
||||
const entry = getSessionEntries(filePath).find((candidate) => candidate.id === entryId);
|
||||
if (!entry || entry.type !== "message" || entry.message.role !== "assistant") {
|
||||
return NextResponse.json({ error: "Assistant message not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const block = entry.message.content[blockIndex];
|
||||
if (!block || block.type !== "thinking") {
|
||||
return NextResponse.json({ error: "Thinking block not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ thinking: block.thinking });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { execFile } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { promisify } from "util";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PiCodingAgentModule = {
|
||||
getPackageDir: () => string;
|
||||
};
|
||||
|
||||
type ExportHtmlModule = {
|
||||
exportFromFile: (inputPath: string, outputPath: string) => Promise<string>;
|
||||
};
|
||||
|
||||
async function getPiPackageDir(): Promise<string | null> {
|
||||
try {
|
||||
const { getPackageDir } = (await import("@earendil-works/pi-coding-agent")) as PiCodingAgentModule;
|
||||
return getPackageDir();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
||||
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(fileName: string, inline: boolean): string {
|
||||
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "session.html";
|
||||
const disposition = inline ? "inline" : "attachment";
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
||||
}
|
||||
|
||||
async function getPiCliPath(): Promise<string | null> {
|
||||
const candidates = new Set<string>();
|
||||
const packageDir = await getPiPackageDir();
|
||||
|
||||
if (packageDir) {
|
||||
candidates.add(join(packageDir, "dist", "cli.js"));
|
||||
}
|
||||
|
||||
try {
|
||||
const resolver = (import.meta as ImportMeta & {
|
||||
resolve?: (specifier: string) => string | Promise<string>;
|
||||
}).resolve;
|
||||
if (typeof resolver === "function") {
|
||||
const indexUrl = await resolver("@earendil-works/pi-coding-agent");
|
||||
candidates.add(join(dirname(fileURLToPath(indexUrl)), "cli.js"));
|
||||
}
|
||||
} catch {
|
||||
// Next.js production bundles can strip import.meta.resolve.
|
||||
}
|
||||
|
||||
candidates.add(
|
||||
join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
"dist",
|
||||
"cli.js"
|
||||
)
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the exported HTML to fix recursive functions that overflow
|
||||
* the call stack on deep linear session trees (e.g., 5000+ entries).
|
||||
*
|
||||
* ## Root Cause
|
||||
* pi-coding-agent's template.js uses recursive helpers to render and
|
||||
* navigate the session tree in the exported HTML:
|
||||
*
|
||||
* 1. sortChildren(node) — recursively sorts children of every node.
|
||||
* Calls itself via node.children.forEach(sortChildren).
|
||||
* On a 5527-entry linear chain (no branches), this recurses 5527
|
||||
* levels deep → stack overflow.
|
||||
*
|
||||
* 2. mapNodes(node) — recursively indexes tree nodes the first time
|
||||
* a tree item is clicked. Same depth -> same overflow.
|
||||
*
|
||||
* 3. markActive(node) — recursively marks nodes on the active path.
|
||||
* Calls itself via markActive(child) for each child.
|
||||
* Same depth → same overflow.
|
||||
*
|
||||
* Both functions are inlined in the HTML by pi-coding-agent at export
|
||||
* time. We cannot modify template.js directly (it's in node_modules
|
||||
* and would be overwritten on npm install). Instead, we patch the
|
||||
* generated HTML string before returning it to the client.
|
||||
*
|
||||
* ## Fix
|
||||
* Replace each recursive function with an iterative equivalent:
|
||||
*
|
||||
* sortChildren → explicit stack (DFS pre-order, push children in
|
||||
* reverse to maintain order)
|
||||
* mapNodes → explicit stack (DFS pre-order)
|
||||
* markActive → two-stack post-order (stack1 for traversal,
|
||||
* stack2 for processing children before parent)
|
||||
*
|
||||
* ## Line Ending Normalization
|
||||
* This file (route.ts) uses CRLF (Windows), while template.js uses LF
|
||||
* (Unix). The template strings in the backtick literals inherit the
|
||||
* file's CRLF line endings. At runtime, readFileSync() also returns
|
||||
* CRLF on Windows. We normalize everything to LF before matching.
|
||||
*
|
||||
* The helper `n(s)` strips \r\n → \n on both the HTML and the
|
||||
* replacement strings, ensuring cross-platform matching.
|
||||
*/
|
||||
function patchExportHtml(html: string): string {
|
||||
// Normalize line endings: route.ts is CRLF, template.js is LF.
|
||||
// Without this, the replace() below would fail on Windows.
|
||||
const n = (s: string) => s.replace(/\r\n/g, "\n");
|
||||
html = n(html);
|
||||
|
||||
const replaceRequired = (source: string, name: string, search: string, replacement: string) => {
|
||||
const normalizedSearch = n(search);
|
||||
const normalizedReplacement = n(replacement);
|
||||
const matches = source.split(normalizedSearch).length - 1;
|
||||
if (matches !== 1) {
|
||||
throw new Error(`Failed to patch exported HTML: ${name} expected 1 match, found ${matches}`);
|
||||
}
|
||||
return source.replace(normalizedSearch, normalizedReplacement);
|
||||
};
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"sortChildren",
|
||||
` function sortChildren(node) {
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
node.children.forEach(sortChildren);
|
||||
}`,
|
||||
` function sortChildren(root) {
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"mapNodes",
|
||||
` function mapNodes(node) {
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
node.children.forEach(mapNodes);
|
||||
}
|
||||
tree.forEach(mapNodes);`,
|
||||
` const stack = [...tree].reverse();
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"markActive",
|
||||
` function markActive(node) {
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (markActive(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
return has;
|
||||
}`,
|
||||
` function markActive(root) {
|
||||
// Post-order traversal using two stacks
|
||||
const stack1 = [root];
|
||||
const stack2 = [];
|
||||
while (stack1.length) {
|
||||
const node = stack1.pop();
|
||||
stack2.push(node);
|
||||
for (const child of node.children) {
|
||||
stack1.push(child);
|
||||
}
|
||||
}
|
||||
while (stack2.length) {
|
||||
const node = stack2.pop();
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (containsActive.get(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async function exportSession(filePath: string, outputPath: string): Promise<void> {
|
||||
const cliPath = await getPiCliPath();
|
||||
if (cliPath) {
|
||||
await execFileAsync(process.execPath, [cliPath, "--export", filePath, outputPath], {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
PI_OFFLINE: "1",
|
||||
PI_SKIP_VERSION_CHECK: "1",
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const packageDir = await getPiPackageDir();
|
||||
if (!packageDir) throw new Error("pi CLI not found");
|
||||
|
||||
const exporterUrl = pathToFileURL(join(packageDir, "dist", "core", "export-html", "index.js")).href;
|
||||
const { exportFromFile } = (await import(exporterUrl)) as ExportHtmlModule;
|
||||
await exportFromFile(filePath, outputPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const inline = new URL(req.url).searchParams.get("inline") === "1";
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const tempDir = join(tmpdir(), "pi-web-export");
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const sessionBase = basename(filePath, ".jsonl");
|
||||
const fileName = `pi-session-${sessionBase}.html`;
|
||||
const outputPath = join(tempDir, `${randomUUID()}.html`);
|
||||
|
||||
try {
|
||||
await exportSession(filePath, outputPath);
|
||||
|
||||
const html = readFileSync(outputPath, "utf8");
|
||||
const patchedHtml = patchExportHtml(html);
|
||||
return new Response(patchedHtml, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": getContentDisposition(fileName, inline),
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(outputPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
resolveSessionPath,
|
||||
resolveSessionIdByPath,
|
||||
invalidateSessionPathCache,
|
||||
invalidateSessionListCache,
|
||||
buildSessionContext,
|
||||
readSessionHeader,
|
||||
} from "@/lib/session-reader";
|
||||
import { sessionPathKey } from "@/lib/session-path";
|
||||
import { getRpcSession } from "@/lib/rpc-manager";
|
||||
|
||||
// BranchNavigator still traverses recursively, so keep the response tree shallow.
|
||||
const MAX_PROJECTED_TREE_DEPTH = 200;
|
||||
|
||||
/**
|
||||
* Project the session tree into the shallow navigation tree sent to the client.
|
||||
* Keeps roots, branch points, and leaves while contracting single-child chains
|
||||
* without recursive traversal. Contracted entry IDs are attached to the next
|
||||
* visible node so the UI can still recognize an active leaf inside the chain.
|
||||
*/
|
||||
function projectTreeForResponse<T extends { entry: { id: string }; children: T[]; compressedEntryIds?: string[] }>(
|
||||
nodes: T[]
|
||||
): T[] {
|
||||
const keep = new Set<T>();
|
||||
const roots = new Set(nodes);
|
||||
const seen = new Set<T>();
|
||||
const stack = [...nodes];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (seen.has(node)) continue;
|
||||
seen.add(node);
|
||||
|
||||
if (
|
||||
roots.has(node) ||
|
||||
node.children.length !== 1
|
||||
) {
|
||||
keep.add(node);
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
const cloneNode = (node: T, compressedEntryIds?: string[]): T => ({
|
||||
...node,
|
||||
children: [],
|
||||
...(compressedEntryIds?.length ? { compressedEntryIds } : {}),
|
||||
});
|
||||
const projectedRoots = nodes.map((node) => cloneNode(node));
|
||||
const tasks = nodes.map((source, index) => ({
|
||||
source,
|
||||
projected: projectedRoots[index],
|
||||
depth: 1,
|
||||
}));
|
||||
|
||||
const appendFlattenedKeptDescendants = (source: T, projectedParent: T) => {
|
||||
const pending = [{ node: source, compressedEntryIds: [] as string[] }];
|
||||
const flattenedSeen = new Set<T>();
|
||||
|
||||
while (pending.length > 0) {
|
||||
const { node, compressedEntryIds } = pending.pop()!;
|
||||
if (flattenedSeen.has(node)) continue;
|
||||
flattenedSeen.add(node);
|
||||
|
||||
if (keep.has(node)) {
|
||||
projectedParent.children.push(cloneNode(node, compressedEntryIds));
|
||||
}
|
||||
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
pending.push({
|
||||
node: node.children[i],
|
||||
compressedEntryIds: keep.has(node)
|
||||
? []
|
||||
: [...compressedEntryIds, node.entry.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while (tasks.length > 0) {
|
||||
const { source, projected, depth } = tasks.pop()!;
|
||||
|
||||
for (const sourceChild of source.children) {
|
||||
let child = sourceChild;
|
||||
|
||||
if (depth >= MAX_PROJECTED_TREE_DEPTH) {
|
||||
appendFlattenedKeptDescendants(child, projected);
|
||||
continue;
|
||||
}
|
||||
|
||||
const compressedEntryIds: string[] = [];
|
||||
while (!keep.has(child) && child.children.length === 1) {
|
||||
compressedEntryIds.push(child.entry.id);
|
||||
child = child.children[0];
|
||||
}
|
||||
|
||||
if (!keep.has(child)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectedChild = cloneNode(child, compressedEntryIds);
|
||||
projected.children.push(projectedChild);
|
||||
tasks.push({ source: child, projected: projectedChild, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return projectedRoots;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sm = SessionManager.open(filePath);
|
||||
const entries = sm.getEntries() as never;
|
||||
const leafId = sm.getLeafId();
|
||||
const tree = projectTreeForResponse(sm.getTree());
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const deferThinking = searchParams.has("deferThinking");
|
||||
const deferToolResultImages = searchParams.has("deferMedia");
|
||||
const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages });
|
||||
|
||||
const header = sm.getHeader();
|
||||
let modified = header?.timestamp ?? new Date().toISOString();
|
||||
try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ }
|
||||
const parentSessionId = header?.parentSession
|
||||
? await resolveSessionIdByPath(header.parentSession)
|
||||
: undefined;
|
||||
const info = header ? {
|
||||
path: filePath,
|
||||
id: header.id,
|
||||
cwd: header.cwd ?? "",
|
||||
name: sm.getSessionName(),
|
||||
created: header.timestamp,
|
||||
modified,
|
||||
messageCount: context.messages.length,
|
||||
firstMessage: context.messages.find((m) => m.role === "user")
|
||||
? (() => {
|
||||
const msg = context.messages.find((m) => m.role === "user")!;
|
||||
const c = (msg as { content: unknown }).content;
|
||||
return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)";
|
||||
})()
|
||||
: "(no messages)",
|
||||
parentSessionId,
|
||||
} : null;
|
||||
|
||||
return NextResponse.json({
|
||||
sessionId: id,
|
||||
filePath,
|
||||
info,
|
||||
leafId,
|
||||
tree,
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/sessions/[id] body: { name: string }
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { name } = await req.json() as { name?: string };
|
||||
if (typeof name !== "string") {
|
||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||
}
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
const sm = SessionManager.open(filePath);
|
||||
sm.appendSessionInfo(name.trim());
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sessions/[id]
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Read only the bounded header before deleting.
|
||||
const parentSessionPath = readSessionHeader(filePath)?.parentSession;
|
||||
|
||||
// Re-attach all direct children to this session's parent (cascade re-parent)
|
||||
// Scan sibling files in the same directory
|
||||
const targetPathKey = sessionPathKey(filePath);
|
||||
const dir = dirname(filePath);
|
||||
try {
|
||||
const files = readdirSync(dir).filter(
|
||||
(file) => file.endsWith(".jsonl") && sessionPathKey(join(dir, file)) !== targetPathKey,
|
||||
);
|
||||
for (const file of files) {
|
||||
const childPath = join(dir, file);
|
||||
try {
|
||||
const content = readFileSync(childPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
const header = JSON.parse(lines[0]) as { type?: string; parentSession?: string };
|
||||
if (
|
||||
header.type === "session" &&
|
||||
header.parentSession &&
|
||||
sessionPathKey(header.parentSession) === targetPathKey
|
||||
) {
|
||||
// Rewrite header with new parentSession
|
||||
header.parentSession = parentSessionPath;
|
||||
lines[0] = JSON.stringify(header);
|
||||
writeFileSync(childPath, lines.join("\n"));
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
} catch { /* skip if dir unreadable */ }
|
||||
|
||||
getRpcSession(id)?.destroy();
|
||||
unlinkSync(filePath);
|
||||
invalidateSessionPathCache(id);
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRpcSession } from "@/lib/rpc-manager";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
if (!await resolveSessionPath(id)) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rpc = getRpcSession(id);
|
||||
if (!rpc?.isAlive()) return NextResponse.json({ running: false });
|
||||
|
||||
const state = await rpc.send({ type: "get_state" });
|
||||
return NextResponse.json({ running: true, state });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listAllSessions } from "@/lib/session-reader";
|
||||
import { getRunningRpcSessionIds } from "@/lib/rpc-manager";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = await listAllSessions();
|
||||
return NextResponse.json({ sessions, runningSessionIds: getRunningRpcSessionIds() });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { SkillInstallScope } from "@/lib/api-types";
|
||||
import { checkSkillUpdates } from "@/lib/skill-updates";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
cwd?: unknown;
|
||||
package?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd : "";
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const pkg = typeof body.package === "string" ? body.package : undefined;
|
||||
const scope = body.scope === "global" || body.scope === "project"
|
||||
? body.scope as SkillInstallScope
|
||||
: undefined;
|
||||
if ((pkg && !scope) || (!pkg && scope)) {
|
||||
return NextResponse.json({ error: "package and scope must be provided together" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { skills } = await loadSkillsWithInstallInfo(cwd);
|
||||
const installs = skills
|
||||
.map((skill) => skill.install)
|
||||
.filter((install): install is NonNullable<typeof install> => Boolean(install))
|
||||
.filter((install) => !pkg || (install.package === pkg && install.scope === scope));
|
||||
|
||||
if (pkg && installs.length === 0) {
|
||||
return NextResponse.json({ error: "Installed skill not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updates = await checkSkillUpdates(installs, {
|
||||
githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
|
||||
});
|
||||
return NextResponse.json({ updates });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
import { getProjectTrustStatus } from "@/lib/project-trust";
|
||||
import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ANSI_RE = /\x1B\[[0-9;]*m/g;
|
||||
|
||||
// POST /api/skills/install body: { package: string; scope: "global" | "project"; cwd?: string }
|
||||
export async function POST(req: Request) {
|
||||
if (!isApiRequestAllowed(req)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
if (!hasJsonContentType(req)) {
|
||||
return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { package: pkg, scope, cwd } = await req.json() as { package?: string; scope?: string; cwd?: string };
|
||||
if (!pkg?.trim()) return NextResponse.json({ error: "package required" }, { status: 400 });
|
||||
|
||||
const isGlobal = isManagedRuntime() || scope !== "project";
|
||||
if (!isGlobal) {
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required for project install" }, { status: 400 });
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
if (!getProjectTrustStatus(cwd, getAgentDir()).trusted) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project resources must be trusted before installing project skills" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const args = ["skills", "add", pkg.trim(), "-y", "--agent", "pi"];
|
||||
if (isGlobal) args.push("-g");
|
||||
|
||||
console.log(`[skills/install] running: npx ${args.join(" ")}`);
|
||||
const { stdout, stderr } = await runNpx(args, {
|
||||
timeout: 60000,
|
||||
cwd: !isGlobal && cwd ? cwd : undefined,
|
||||
env: getSkillsCliEnvironment(),
|
||||
});
|
||||
|
||||
const output = (stdout + stderr).replace(ANSI_RE, "");
|
||||
const success = /Installation complete|Installed \d+ skill/.test(output);
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: output.slice(-300) || "Install failed" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ success: true, output });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = ((err.stdout ?? "") + (err.stderr ?? "")).replace(ANSI_RE, "");
|
||||
return NextResponse.json({ error: output || (err.message ?? String(e)) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/skills?cwd=<path>
|
||||
// Uses DefaultResourceLoader (same logic as AgentSession startup) so settings.json
|
||||
// skill paths, package skills, and .agents/skills directories are all included.
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const cwd = searchParams.get("cwd");
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(await loadSkillsWithInstallInfo(cwd));
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/skills — toggle disable-model-invocation on a SKILL.md file
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { filePath: string; disableModelInvocation: boolean };
|
||||
const { filePath, disableModelInvocation } = body;
|
||||
if (!filePath) return NextResponse.json({ error: "filePath required" }, { status: 400 });
|
||||
if (!existsSync(filePath)) return NextResponse.json({ error: "file not found" }, { status: 404 });
|
||||
const allowedRoots = new Set(await getAllowedFileRoots());
|
||||
allowedRoots.add(getAgentDir());
|
||||
if (isManagedRuntime()) {
|
||||
for (const root of getManagedRuntimePaths().managedSkillRoots) {
|
||||
if (existsSync(root)) allowedRoots.add(root);
|
||||
}
|
||||
} else {
|
||||
// Upstream-compatible mode keeps the CLI's user-wide skill root.
|
||||
const globalSkillsDir = path.join(homedir(), ".agents", "skills");
|
||||
if (existsSync(globalSkillsDir)) allowedRoots.add(globalSkillsDir);
|
||||
}
|
||||
if (!isExistingFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const key = "disable-model-invocation";
|
||||
|
||||
// Use parseFrontmatter to check current value, then do a surgical line edit
|
||||
// to preserve the original YAML formatting of all other fields.
|
||||
const { frontmatter } = parseFrontmatter<Record<string, unknown>>(content);
|
||||
const alreadySet = Boolean(frontmatter[key]);
|
||||
|
||||
let updated = content;
|
||||
if (disableModelInvocation && !alreadySet) {
|
||||
// Add key after the opening --- line
|
||||
updated = content.replace(/^---\r?\n/, `---\n${key}: true\n`);
|
||||
// If no frontmatter exists, create one
|
||||
if (updated === content) updated = `---\n${key}: true\n---\n${content}`;
|
||||
} else if (!disableModelInvocation && alreadySet) {
|
||||
// Remove the key line entirely
|
||||
updated = content.replace(new RegExp(`^${key}\\s*:.*\\r?\\n`, "m"), "");
|
||||
}
|
||||
|
||||
writeFileSync(filePath, updated, "utf8");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import type { SkillSearchResult } from "@/lib/api-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ANSI_RE = /\x1B\[[0-9;]*m/g;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MIN_LIMIT = 1;
|
||||
const MAX_LIMIT = 50;
|
||||
const SEARCH_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh";
|
||||
|
||||
interface SkillsApiSkill {
|
||||
id?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
installs?: number;
|
||||
}
|
||||
|
||||
interface SkillsApiResponse {
|
||||
skills?: SkillsApiSkill[];
|
||||
}
|
||||
|
||||
function parseLimit(value: unknown): number {
|
||||
const num = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(num)) return DEFAULT_LIMIT;
|
||||
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(num)));
|
||||
}
|
||||
|
||||
function formatInstalls(count?: number): string {
|
||||
if (!count || count <= 0) return "";
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`;
|
||||
return `${count} install${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function parseSearchOutput(raw: string): SkillSearchResult[] {
|
||||
const clean = raw.replace(ANSI_RE, "");
|
||||
const results: SkillSearchResult[] = [];
|
||||
const lines = clean.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
// package line: "owner/repo@skill NNK installs"
|
||||
const pkgMatch = line.match(/^([\w.\-]+\/[\w.\-@:]+)\s+([\d.,]+[KMB]?\s+installs)$/);
|
||||
if (pkgMatch) {
|
||||
const urlLine = lines[i + 1]?.trim().replace(/^└\s*/, "");
|
||||
results.push({
|
||||
package: pkgMatch[1],
|
||||
installs: pkgMatch[2],
|
||||
url: urlLine?.startsWith("https://") ? urlLine : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function searchSkillsApi(query: string, limit: number): Promise<SkillSearchResult[]> {
|
||||
const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`skills.sh search failed: HTTP ${res.status}`);
|
||||
|
||||
const data = (await res.json()) as SkillsApiResponse;
|
||||
return (data.skills ?? [])
|
||||
.map((skill) => {
|
||||
const name = skill.name?.trim();
|
||||
const source = skill.source?.trim();
|
||||
const slug = skill.id?.trim();
|
||||
if (!name || (!source && !slug)) return null;
|
||||
|
||||
const pkg = `${source || slug}@${name}`;
|
||||
return {
|
||||
package: pkg,
|
||||
installs: formatInstalls(skill.installs),
|
||||
url: slug ? `${SEARCH_API_BASE}/${slug}` : "",
|
||||
};
|
||||
})
|
||||
.filter((skill): skill is SkillSearchResult => skill !== null)
|
||||
.sort((a, b) => parseInstallCount(b.installs) - parseInstallCount(a.installs));
|
||||
}
|
||||
|
||||
function parseInstallCount(installs: string): number {
|
||||
const match = installs.match(/^([\d.]+)([KMB])?\s+installs?$/);
|
||||
if (!match) return 0;
|
||||
const value = Number(match[1]);
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
const multiplier = match[2] === "B" ? 1_000_000_000 : match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1;
|
||||
return value * multiplier;
|
||||
}
|
||||
|
||||
// POST /api/skills/search body: { query: string, limit?: number }
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { query, limit: rawLimit } = await req.json() as { query?: string; limit?: unknown };
|
||||
if (!query?.trim()) return NextResponse.json({ error: "query required" }, { status: 400 });
|
||||
const limit = parseLimit(rawLimit);
|
||||
|
||||
try {
|
||||
const results = await searchSkillsApi(query.trim(), limit);
|
||||
return NextResponse.json({ results });
|
||||
} catch {
|
||||
const { stdout, stderr } = await runNpx(["skills", "find", query.trim()], {
|
||||
timeout: 20000,
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
});
|
||||
|
||||
const results = parseSearchOutput(stdout + stderr).slice(0, limit);
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
||||
const raw = (err.stdout ?? "") + (err.stderr ?? "");
|
||||
const results = raw ? parseSearchOutput(raw) : [];
|
||||
if (results.length > 0) return NextResponse.json({ results });
|
||||
return NextResponse.json({ error: err.message ?? String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import type { SkillInstallScope } from "@/lib/api-types";
|
||||
import { buildSkillUpdateArgs } from "@/lib/skill-updates";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
cwd?: unknown;
|
||||
package?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd : "";
|
||||
const pkg = typeof body.package === "string" ? body.package : "";
|
||||
const scope = body.scope === "global" || body.scope === "project"
|
||||
? body.scope as SkillInstallScope
|
||||
: undefined;
|
||||
if (!cwd || !pkg || !scope) {
|
||||
return NextResponse.json({ error: "cwd, package, and scope are required" }, { status: 400 });
|
||||
}
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { skills } = await loadSkillsWithInstallInfo(cwd);
|
||||
const skill = skills.find(
|
||||
(item) => item.install?.package === pkg && item.install.scope === scope,
|
||||
);
|
||||
if (!skill?.install) {
|
||||
return NextResponse.json({ error: "Installed skill not found" }, { status: 404 });
|
||||
}
|
||||
if (!skill.install.canCheckForUpdates) {
|
||||
return NextResponse.json({ error: "This skill cannot be updated automatically" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await runNpx(buildSkillUpdateArgs(skill.install), {
|
||||
timeout: 60_000,
|
||||
cwd: !isManagedRuntime() && scope === "project" ? cwd : undefined,
|
||||
env: getSkillsCliEnvironment(),
|
||||
});
|
||||
|
||||
const refreshed = await loadSkillsWithInstallInfo(cwd);
|
||||
const updatedSkill = refreshed.skills.find(
|
||||
(item) => item.install?.package === pkg && item.install.scope === scope,
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skill: updatedSkill,
|
||||
output: `${stdout}${stderr}`.slice(-500),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const detail = error as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = `${detail.stdout ?? ""}${detail.stderr ?? ""}`;
|
||||
return NextResponse.json(
|
||||
{ error: output || detail.message || String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface VisionConfigFile {
|
||||
backend?: "ollama" | "openai";
|
||||
ollama?: { host?: string; model?: string };
|
||||
openai?: { baseUrl?: string; apiKey?: string; model?: string };
|
||||
}
|
||||
|
||||
function getVisionPath(): string {
|
||||
return join(getAgentDir(), "vision.json");
|
||||
}
|
||||
|
||||
function readVisionJson(): VisionConfigFile {
|
||||
const path = getVisionPath();
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as VisionConfigFile;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeVisionJson(data: VisionConfigFile): void {
|
||||
const path = getVisionPath();
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ config: readVisionJson(), path: getVisionPath() });
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as VisionConfigFile;
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "body must be an object" }, { status: 400 });
|
||||
}
|
||||
const out: VisionConfigFile = {};
|
||||
if (body.backend === "ollama" || body.backend === "openai") out.backend = body.backend;
|
||||
if (body.ollama && typeof body.ollama === "object") {
|
||||
out.ollama = {};
|
||||
if (typeof body.ollama.host === "string" && body.ollama.host.trim()) out.ollama.host = body.ollama.host.trim();
|
||||
if (typeof body.ollama.model === "string" && body.ollama.model.trim()) out.ollama.model = body.ollama.model.trim();
|
||||
}
|
||||
if (body.openai && typeof body.openai === "object") {
|
||||
out.openai = {};
|
||||
if (typeof body.openai.baseUrl === "string" && body.openai.baseUrl.trim()) out.openai.baseUrl = body.openai.baseUrl.trim();
|
||||
if (typeof body.openai.apiKey === "string" && body.openai.apiKey.trim()) out.openai.apiKey = body.openai.apiKey.trim();
|
||||
if (typeof body.openai.model === "string" && body.openai.model.trim()) out.openai.model = body.openai.model.trim();
|
||||
}
|
||||
writeVisionJson(out);
|
||||
return NextResponse.json({ success: true, path: getVisionPath() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { addWorktree, listWorktrees, removeWorktree, resolveProject } from "@/lib/worktree";
|
||||
import { allowFileRoot, getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed } from "@/lib/file-access";
|
||||
|
||||
/** Same gate as /api/files: only session cwds / project roots / explicitly
|
||||
* allowed dirs may be inspected or mutated through this endpoint. */
|
||||
async function checkCwdAllowed(cwd: string): Promise<NextResponse | null> {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots) || !isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/worktrees?cwd= → { projectRoot, isGit, isTopLevel, worktrees }
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const cwd = new URL(req.url).searchParams.get("cwd");
|
||||
if (!cwd) {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(cwd);
|
||||
if (denied) return denied;
|
||||
|
||||
const project = await resolveProject(cwd);
|
||||
let worktrees: Awaited<ReturnType<typeof listWorktrees>> = [];
|
||||
let isGit = true;
|
||||
try {
|
||||
// For a removed-worktree cwd (session of a deleted worktree), fall back
|
||||
// to the inferred project root so the switcher still shows the project.
|
||||
worktrees = await listWorktrees(existsSync(cwd) ? cwd : project.projectRoot);
|
||||
} catch {
|
||||
isGit = false;
|
||||
}
|
||||
// Every listed path is a git-verified worktree of this project; allow the
|
||||
// file explorer to browse them even before they have any session (the
|
||||
// in-memory allowlist from addWorktree does not survive server restarts).
|
||||
for (const w of worktrees) allowFileRoot(w.path);
|
||||
return NextResponse.json({
|
||||
projectRoot: project.projectRoot,
|
||||
isGit,
|
||||
isTopLevel: project.isTopLevel,
|
||||
worktrees,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/worktrees body: { cwd, branch } → { path, branch }
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; branch?: string };
|
||||
if (!body.cwd || typeof body.cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!body.branch || typeof body.branch !== "string") {
|
||||
return NextResponse.json({ error: "branch is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(body.cwd);
|
||||
if (denied) return denied;
|
||||
if (!existsSync(body.cwd)) {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${body.cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await addWorktree(body.cwd, body.branch);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/worktrees body: { cwd, path, force? }
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; path?: string; force?: boolean };
|
||||
if (!body.cwd || typeof body.cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!body.path || typeof body.path !== "string") {
|
||||
return NextResponse.json({ error: "path is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(body.cwd);
|
||||
if (denied) return denied;
|
||||
|
||||
await removeWorktree(body.cwd, body.path, body.force === true);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// git refuses to remove dirty worktrees without --force; surface that so
|
||||
// the UI can offer a force-remove confirmation.
|
||||
const dirty = /contains modified or untracked files|is dirty/i.test(message);
|
||||
return NextResponse.json({ error: message, dirty }, { status: dirty ? 409 : 400 });
|
||||
}
|
||||
}
|
||||
+1617
-375
File diff suppressed because it is too large
Load Diff
@@ -207,11 +207,14 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
isNew,
|
||||
sessionIdRef, messagesEndRef, scrollContainerRef,
|
||||
lastUserMsgRef,
|
||||
atBottom,
|
||||
followStreamRef,
|
||||
handleSend, handleAbort, handleFork, handleNavigate, handleModelChange,
|
||||
handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction,
|
||||
handleRecallQueue,
|
||||
handleBuiltinSlashCommand,
|
||||
handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands,
|
||||
scrollToBottom,
|
||||
} = useAgentSession({
|
||||
session, newSessionCwd, onAgentEnd: wrappedOnAgentEnd, onSessionCreated, onSessionForked,
|
||||
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen,
|
||||
@@ -259,6 +262,30 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
container.scrollTop = restoreScrollTop(container.scrollHeight, prevScrollDistanceRef.current);
|
||||
prevScrollDistanceRef.current = null;
|
||||
}, [visibleCount, scrollContainerRef]);
|
||||
|
||||
// Live-follow the model's streaming output: pin the streaming bubble to
|
||||
// the bottom of the viewport on every chunk while auto-follow is engaged
|
||||
// (the default). Scrolling away from the bottom flips followStreamRef off
|
||||
// inside useAgentSession; scrolling back to the bottom or clicking the
|
||||
// scroll-to-bottom button re-engages it.
|
||||
const streamBubbleRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!streamState.isStreaming || !streamState.streamingMessage) return;
|
||||
if (!followStreamRef.current) return;
|
||||
streamBubbleRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||||
}, [streamState]);
|
||||
|
||||
// Jump back to the newest content: scroll to the streaming bubble while a
|
||||
// stream is live (its bottom edge is the live bottom), otherwise to the
|
||||
// end of the conversation. Also re-engages auto-follow.
|
||||
const handleScrollToConversationBottom = useCallback(() => {
|
||||
followStreamRef.current = true;
|
||||
if (streamState.isStreaming && streamState.streamingMessage) {
|
||||
streamBubbleRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
} else {
|
||||
scrollToBottom("smooth");
|
||||
}
|
||||
}, [streamState, followStreamRef, scrollToBottom]);
|
||||
// Push session stats up to AppShell for the top bar.
|
||||
// Compare scalar fields to avoid loops from new object identity each render.
|
||||
const statsKey = sessionStats
|
||||
@@ -341,6 +368,36 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
onSteer={agentRunning ? handleSteer : undefined}
|
||||
onFollowUp={agentRunning ? handleFollowUp : undefined}
|
||||
onPromptWithStreamingBehavior={agentRunning ? handlePromptWithStreamingBehavior : undefined}
|
||||
scrollToBottomButton={
|
||||
isEmptyNew ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollToConversationBottom}
|
||||
aria-label={t("chat.scrollToBottom")}
|
||||
title={t("chat.scrollToBottom")}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
alignSelf: "flex-end",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--bg-panel)",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
opacity: atBottom ? 0.55 : 1,
|
||||
transition: "opacity 0.15s, background 0.15s",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
isStreaming={sessionBusy}
|
||||
model={displayModelValue}
|
||||
isAutoModelSelection={isAutoModelSelection}
|
||||
@@ -683,7 +740,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
);
|
||||
})()}
|
||||
{streamState.isStreaming && streamState.streamingMessage && (
|
||||
<div ref={streamBubbleRef}>
|
||||
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentRunning && !streamState.streamingMessage && (
|
||||
@@ -710,10 +769,6 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
|
||||
/>
|
||||
)}
|
||||
|
||||
{agentRunning && (
|
||||
<div style={{ height: scrollContainerRef.current ? scrollContainerRef.current.clientHeight : "80vh" }} />
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+923
-256
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const { configureHttpDispatcher } = await import("@/lib/http-dispatcher");
|
||||
configureHttpDispatcher();
|
||||
}
|
||||
+17
-1
@@ -1,4 +1,20 @@
|
||||
import type { ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
|
||||
// Structural copy of pi-coding-agent's ResourceDiagnostic/ResourceCollision so
|
||||
// the frontend no longer depends on the pi packages (the backend owns them).
|
||||
export interface ResourceCollision {
|
||||
resourceType: "extension" | "skill" | "prompt" | "theme";
|
||||
name: string;
|
||||
winnerPath: string;
|
||||
loserPath: string;
|
||||
winnerSource?: string;
|
||||
loserSource?: string;
|
||||
}
|
||||
|
||||
export interface ResourceDiagnostic {
|
||||
type: "warning" | "error" | "collision";
|
||||
message: string;
|
||||
path?: string;
|
||||
collision?: ResourceCollision;
|
||||
}
|
||||
|
||||
export interface SkillSearchResult {
|
||||
package: string;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import test from "node:test";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
];
|
||||
|
||||
test("configures HTTP_PROXY, HTTPS_PROXY, and NO_PROXY for global fetch", async (t) => {
|
||||
const originalEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
for (const key of PROXY_ENV_KEYS) delete process.env[key];
|
||||
|
||||
const connectTargets = [];
|
||||
const tunneledRequests = [];
|
||||
const proxy = createServer((req, res) => {
|
||||
res.writeHead(204, { Connection: "close" });
|
||||
res.end();
|
||||
});
|
||||
proxy.on("connect", (req, socket) => {
|
||||
connectTargets.push(req.url);
|
||||
if (req.url?.endsWith(":80")) {
|
||||
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
socket.once("data", (chunk) => {
|
||||
tunneledRequests.push(chunk.toString("utf8").split("\r\n", 1)[0]);
|
||||
socket.end("HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
||||
});
|
||||
return;
|
||||
}
|
||||
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
||||
});
|
||||
proxy.listen(0, "127.0.0.1");
|
||||
await once(proxy, "listening");
|
||||
|
||||
t.after(async () => {
|
||||
for (const [key, value] of originalEnv) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
proxy.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
});
|
||||
|
||||
const address = proxy.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const proxyUrl = `http://127.0.0.1:${address.port}`;
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
process.env.HTTPS_PROXY = proxyUrl;
|
||||
process.env.NO_PROXY = "bypass.invalid";
|
||||
|
||||
const jiti = createJiti(import.meta.url);
|
||||
const { configureHttpDispatcher } = await jiti.import("./http-dispatcher.ts");
|
||||
const { getGlobalDispatcher } = await import("undici");
|
||||
|
||||
assert.throws(() => configureHttpDispatcher(-1), /Invalid HTTP idle timeout/);
|
||||
configureHttpDispatcher(2_000);
|
||||
|
||||
const dispatcher = getGlobalDispatcher();
|
||||
configureHttpDispatcher(5_000);
|
||||
assert.equal(getGlobalDispatcher(), dispatcher, "configuration should be idempotent");
|
||||
|
||||
const httpResponse = await fetch("http://target.invalid/through-http-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
assert.equal(httpResponse.status, 204);
|
||||
assert.deepEqual(connectTargets, ["target.invalid:80"]);
|
||||
assert.deepEqual(tunneledRequests, ["GET /through-http-proxy HTTP/1.1"]);
|
||||
|
||||
await assert.rejects(fetch("https://target.invalid/through-https-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}));
|
||||
assert.deepEqual(connectTargets, ["target.invalid:80", "target.invalid:443"]);
|
||||
|
||||
const proxiedRequestCount = connectTargets.length;
|
||||
await assert.rejects(fetch("http://bypass.invalid:9/no-proxy", {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}));
|
||||
assert.equal(connectTargets.length, proxiedRequestCount);
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as undici from "undici";
|
||||
|
||||
export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
|
||||
|
||||
type DispatcherGlobal = typeof globalThis & {
|
||||
__piWebHttpDispatcherConfigured?: boolean;
|
||||
};
|
||||
|
||||
const dispatcherGlobal = globalThis as DispatcherGlobal;
|
||||
const originalGlobalFetch = globalThis.fetch;
|
||||
const ignoreUndiciDispatcherError = (): void => {};
|
||||
|
||||
function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.toLowerCase() === "disabled") return 0;
|
||||
if (trimmed.length === 0) return undefined;
|
||||
return parseHttpIdleTimeoutMs(Number(trimmed));
|
||||
}
|
||||
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
// Undici can emit an internal Client error while terminating a response body.
|
||||
// The body stream still rejects; this prevents the EventEmitter error from
|
||||
// terminating the Next.js process first.
|
||||
function withUndiciErrorListener<T extends undici.Dispatcher>(dispatcher: T): T {
|
||||
if (dispatcher instanceof EventEmitter) {
|
||||
EventEmitter.prototype.on.call(dispatcher, "error", ignoreUndiciDispatcherError);
|
||||
}
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
function createUndiciClient(origin: string | URL, options: object): undici.Dispatcher {
|
||||
return withUndiciErrorListener(
|
||||
new undici.Client(origin, options as undici.Client.Options),
|
||||
);
|
||||
}
|
||||
|
||||
function createUndiciOriginDispatcher(origin: string | URL, options: object): undici.Dispatcher {
|
||||
const dispatcherOptions = options as undici.Pool.Options;
|
||||
if (dispatcherOptions.connections === 1) {
|
||||
return createUndiciClient(origin, dispatcherOptions);
|
||||
}
|
||||
|
||||
return withUndiciErrorListener(
|
||||
new undici.Pool(origin, {
|
||||
...dispatcherOptions,
|
||||
factory: createUndiciClient,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function configureHttpDispatcher(
|
||||
timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS,
|
||||
): void {
|
||||
if (dispatcherGlobal.__piWebHttpDispatcherConfigured) return;
|
||||
|
||||
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
|
||||
if (normalizedTimeoutMs === undefined) {
|
||||
throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`);
|
||||
}
|
||||
|
||||
const dispatcher = withUndiciErrorListener(
|
||||
new undici.EnvHttpProxyAgent({
|
||||
allowH2: false,
|
||||
bodyTimeout: normalizedTimeoutMs,
|
||||
headersTimeout: normalizedTimeoutMs,
|
||||
clientFactory: createUndiciClient,
|
||||
factory: createUndiciOriginDispatcher,
|
||||
}),
|
||||
);
|
||||
undici.setGlobalDispatcher(dispatcher);
|
||||
|
||||
// Keep fetch and the dispatcher on the same undici implementation. Preserve
|
||||
// an intentional fetch override installed after this module was loaded.
|
||||
if (globalThis.fetch === originalGlobalFetch) {
|
||||
undici.install?.();
|
||||
}
|
||||
|
||||
dispatcherGlobal.__piWebHttpDispatcherConfigured = true;
|
||||
}
|
||||
@@ -240,6 +240,7 @@ export const enLocale: LocalePlugin = {
|
||||
"chat.commandCopy": "Copy the last assistant message",
|
||||
"chat.compacted": "Compacted",
|
||||
"chat.tokensSaved": "{saved} saved",
|
||||
"chat.scrollToBottom": "Scroll to bottom",
|
||||
"mcp.addServer": "Add server",
|
||||
"mcp.addEnv": "Add env",
|
||||
"mcp.serverName": "Server name",
|
||||
|
||||
@@ -240,6 +240,7 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"chat.commandCopy": "复制最后一条助手消息",
|
||||
"chat.compacted": "已压缩",
|
||||
"chat.tokensSaved": "节省 {saved}",
|
||||
"chat.scrollToBottom": "滚动到底部",
|
||||
"mcp.addServer": "添加服务器",
|
||||
"mcp.addEnv": "添加环境变量",
|
||||
"mcp.serverName": "服务器名称",
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
export interface ModelsData {
|
||||
models: Record<string, string>;
|
||||
modelList: { id: string; name: string; provider: string }[];
|
||||
defaultModel: { provider: string; modelId: string } | null;
|
||||
thinkingLevels: Record<string, string[]>;
|
||||
thinkingLevelMaps: Record<string, Record<string, string | null>>;
|
||||
modelError?: string;
|
||||
}
|
||||
|
||||
interface ModelsCacheState {
|
||||
entries: Map<string, { data: ModelsData; expiresAt: number }>;
|
||||
inFlight: Map<string, Promise<ModelsData>>;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __piModelsCacheState: ModelsCacheState | undefined;
|
||||
}
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
const MAX_MODELS_CACHE_ENTRIES = 32;
|
||||
|
||||
function getModelsCacheState(): ModelsCacheState {
|
||||
if (!globalThis.__piModelsCacheState) {
|
||||
globalThis.__piModelsCacheState = {
|
||||
entries: new Map(),
|
||||
inFlight: new Map(),
|
||||
generation: 0,
|
||||
};
|
||||
}
|
||||
return globalThis.__piModelsCacheState;
|
||||
}
|
||||
|
||||
export function invalidateModelsCache(): void {
|
||||
const state = getModelsCacheState();
|
||||
state.generation += 1;
|
||||
state.entries.clear();
|
||||
state.inFlight.clear();
|
||||
}
|
||||
|
||||
export function withModelRuntimeError(data: ModelsData, modelError: string | undefined): ModelsData {
|
||||
return modelError ? { ...data, modelError } : data;
|
||||
}
|
||||
|
||||
export function loadModelsWithCache(cwd: string, loader: () => Promise<ModelsData>): Promise<ModelsData> {
|
||||
const state = getModelsCacheState();
|
||||
const cached = state.entries.get(cwd);
|
||||
if (cached) {
|
||||
if (cached.expiresAt > Date.now()) return Promise.resolve(cached.data);
|
||||
state.entries.delete(cwd);
|
||||
}
|
||||
|
||||
const existingLoad = state.inFlight.get(cwd);
|
||||
if (existingLoad) return existingLoad;
|
||||
|
||||
const generation = state.generation;
|
||||
const loadPromise: Promise<ModelsData> = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((data) => {
|
||||
if (state.generation === generation && state.inFlight.get(cwd) === loadPromise) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of state.entries) {
|
||||
if (entry.expiresAt <= now) state.entries.delete(key);
|
||||
}
|
||||
while (state.entries.size >= MAX_MODELS_CACHE_ENTRIES) {
|
||||
const oldestKey = state.entries.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
state.entries.delete(oldestKey);
|
||||
}
|
||||
state.entries.set(cwd, { data, expiresAt: now + MODELS_CACHE_TTL_MS });
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.inFlight.get(cwd) === loadPromise) state.inFlight.delete(cwd);
|
||||
});
|
||||
|
||||
state.inFlight.set(cwd, loadPromise);
|
||||
return loadPromise;
|
||||
}
|
||||
+4
-141
@@ -1,10 +1,7 @@
|
||||
import type {
|
||||
AgentSessionEvent,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
SlashCommandInfo,
|
||||
Theme,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
// Frontend-only subset of the pi type surface. The full AgentSessionLike
|
||||
// contract (which references pi-coding-agent classes) lives in the backend
|
||||
// (server/src/lib/pi-types.ts); the UI only renders session stats reported
|
||||
// over HTTP, so this file must not import the pi packages.
|
||||
|
||||
export interface ContextUsage {
|
||||
percent: number | null;
|
||||
@@ -12,22 +9,6 @@ export interface ContextUsage {
|
||||
tokens: number | null;
|
||||
}
|
||||
|
||||
export interface ModelLike {
|
||||
id: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NavigateTreeResult {
|
||||
editorText?: string;
|
||||
cancelled: boolean;
|
||||
aborted?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionStatsInfo {
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
@@ -47,121 +28,3 @@ export interface SessionStatsInfo {
|
||||
cost: number;
|
||||
contextUsage?: ContextUsage;
|
||||
}
|
||||
|
||||
interface PromptTemplateLike {
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}
|
||||
|
||||
interface SkillLike {
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}
|
||||
|
||||
interface ResourceLoaderLike {
|
||||
getSkills(): { skills: SkillLike[] };
|
||||
}
|
||||
|
||||
interface ExtensionRunnerLike {
|
||||
getRegisteredCommands(): Array<{
|
||||
invocationName: string;
|
||||
description?: string;
|
||||
sourceInfo: SlashCommandInfo["sourceInfo"];
|
||||
}>;
|
||||
setUIContext?(uiContext?: unknown, mode?: "tui" | "rpc" | "json" | "print"): void;
|
||||
}
|
||||
|
||||
type DialogOptionsLike = {
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
type WidgetOptionsLike = {
|
||||
placement?: "aboveEditor" | "belowEditor";
|
||||
};
|
||||
|
||||
export interface ExtensionUiContextLike {
|
||||
select(title: string, options: string[], opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
confirm(title: string, message: string, opts?: DialogOptionsLike): Promise<boolean>;
|
||||
input(title: string, placeholder?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
editor(title: string, prefill?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
onTerminalInput(): () => void;
|
||||
setStatus(key: string, text: string | undefined): void;
|
||||
setWorkingMessage(message?: string): void;
|
||||
setWorkingVisible(visible: boolean): void;
|
||||
setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void;
|
||||
setHiddenThinkingLabel(label?: string): void;
|
||||
setWidget(key: string, content: string[] | ((...args: never[]) => unknown) | undefined, options?: WidgetOptionsLike): void;
|
||||
setFooter(factory: unknown): void;
|
||||
setHeader(factory: unknown): void;
|
||||
setTitle(title: string): void;
|
||||
custom<T = unknown>(...args: unknown[]): Promise<T>;
|
||||
pasteToEditor(text: string): void;
|
||||
setEditorText(text: string): void;
|
||||
getEditorText(): string;
|
||||
addAutocompleteProvider(): void;
|
||||
setEditorComponent(): void;
|
||||
getEditorComponent(): undefined;
|
||||
readonly theme: Theme;
|
||||
getAllThemes(): unknown[];
|
||||
getTheme(name: string): undefined;
|
||||
setTheme(theme: unknown): { success: boolean; error?: string };
|
||||
getToolsExpanded(): boolean;
|
||||
setToolsExpanded(expanded: boolean): void;
|
||||
}
|
||||
|
||||
export interface AgentSessionLike {
|
||||
readonly sessionId: string;
|
||||
readonly sessionFile: string | undefined;
|
||||
readonly isStreaming: boolean;
|
||||
readonly isCompacting: boolean;
|
||||
readonly autoCompactionEnabled: boolean;
|
||||
readonly autoRetryEnabled: boolean;
|
||||
readonly model: ModelLike | undefined;
|
||||
readonly modelRuntime: {
|
||||
getModel: (provider: string, modelId: string) => ModelLike | undefined;
|
||||
refresh: (options?: { allowNetwork?: boolean }) => Promise<unknown>;
|
||||
};
|
||||
readonly sessionManager: SessionManager;
|
||||
readonly settingsManager: SettingsManager;
|
||||
readonly agent: { state?: { systemPrompt?: string; thinkingLevel?: string } };
|
||||
readonly extensionRunner: ExtensionRunnerLike;
|
||||
readonly promptTemplates: readonly PromptTemplateLike[];
|
||||
readonly resourceLoader: ResourceLoaderLike;
|
||||
|
||||
readonly bindExtensions?: unknown;
|
||||
reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void>;
|
||||
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
|
||||
prompt(text: string, options?: {
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>;
|
||||
streamingBehavior?: "steer" | "followUp";
|
||||
source?: "interactive" | "rpc";
|
||||
}): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode?: number; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string }>;
|
||||
abortBash(): void;
|
||||
readonly isBashRunning: boolean;
|
||||
setModel(model: ModelLike): Promise<void>;
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<NavigateTreeResult>;
|
||||
setThinkingLevel(level: string): void;
|
||||
compact(customInstructions?: string): Promise<unknown>;
|
||||
setSessionName(name: string): void;
|
||||
getSessionStats(): Omit<SessionStatsInfo, "sessionName">;
|
||||
getLastAssistantText(): string | undefined;
|
||||
setAutoCompactionEnabled(enabled: boolean): void;
|
||||
setAutoRetryEnabled(enabled: boolean): void;
|
||||
steer(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
|
||||
followUp(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
|
||||
readonly pendingMessageCount: number;
|
||||
getSteeringMessages(): readonly string[];
|
||||
getFollowUpMessages(): readonly string[];
|
||||
clearQueue(): { steering: string[]; followUp: string[] };
|
||||
getAllTools(): ToolInfo[];
|
||||
getActiveToolNames(): string[];
|
||||
setActiveToolsByName(names: string[]): void;
|
||||
abortCompaction(): void;
|
||||
getContextUsage(): ContextUsage | undefined;
|
||||
}
|
||||
|
||||
+20
-8
@@ -5,7 +5,7 @@ import { join } from "path";
|
||||
const { version } = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")) as { version: string };
|
||||
let piVersion = "unknown";
|
||||
try {
|
||||
const piPkgPath = join(__dirname, "node_modules/@earendil-works/pi-coding-agent/package.json");
|
||||
const piPkgPath = join(__dirname, "..", "server", "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
|
||||
piVersion = (JSON.parse(readFileSync(piPkgPath, "utf8")) as { version: string }).version;
|
||||
} catch { /* package not found, use default */ }
|
||||
|
||||
@@ -14,16 +14,28 @@ const allowedDevOrigins = [
|
||||
...(process.env.PI_WEB_ALLOWED_HOSTS?.split(",") ?? []),
|
||||
].map((origin) => origin.trim()).filter(Boolean);
|
||||
|
||||
// All /api routes are served by the standalone backend (pi-agent-server) so
|
||||
// agent sessions and server state survive frontend recompiles and restarts.
|
||||
const serverOrigin = `http://127.0.0.1:${process.env.PI_SERVER_PORT ?? "30142"}`;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
serverExternalPackages: [
|
||||
"undici",
|
||||
"@earendil-works/pi-coding-agent",
|
||||
"@earendil-works/pi-agent-core",
|
||||
"@earendil-works/pi-ai",
|
||||
"@earendil-works/pi-tui",
|
||||
],
|
||||
allowedDevOrigins,
|
||||
async rewrites() {
|
||||
// beforeFiles is required: default (afterFiles) rewrites lose to the
|
||||
// existing app/api route handlers in dev, so the migrated routes
|
||||
// would keep hitting the local copies instead of the backend.
|
||||
return {
|
||||
beforeFiles: [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${serverOrigin}/api/:path*`,
|
||||
},
|
||||
],
|
||||
afterFiles: [],
|
||||
fallback: [],
|
||||
};
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
Generated
+22
-1828
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -38,14 +38,9 @@
|
||||
"release": "npm version patch --no-git-tag-version && npm run build && npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "file:../pi/packages/agent",
|
||||
"@earendil-works/pi-ai": "file:../pi/packages/ai",
|
||||
"@earendil-works/pi-coding-agent": "file:../pi/packages/coding-agent",
|
||||
"@earendil-works/pi-tui": "file:../pi/packages/tui",
|
||||
"next": "16.2.12",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"undici": "8.5.0"
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobehub/icons": "^5.6.0",
|
||||
@@ -56,9 +51,7 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.12",
|
||||
"jiti": "^2.7.0",
|
||||
"katex": "^0.16.47",
|
||||
"mammoth": "^1.12.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"postcss": "^8",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { isApiRequestAllowed } from "@/lib/request-security";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
if (!isApiRequestAllowed(request)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = { matcher: "/api/:path*" };
|
||||
@@ -41,6 +41,7 @@ export function buildBaseOptions(
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
serviceTier: options?.serviceTier,
|
||||
env: options?.env,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +113,13 @@ export interface ProviderResponse {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-level service tier for providers that support it.
|
||||
* OpenAI Codex / Responses: "priority" is fast mode (higher cost, lower latency), "flex" is the cheaper tier.
|
||||
* See https://developers.openai.com/codex/speed for Codex fast mode semantics.
|
||||
*/
|
||||
export type ServiceTier = "auto" | "default" | "flex" | "priority" | "scale";
|
||||
|
||||
export interface StreamOptions {
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
@@ -189,6 +196,12 @@ export interface StreamOptions {
|
||||
* For example, Anthropic uses `user_id` for abuse tracking and rate limiting.
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Request-level service tier. Providers that understand it (OpenAI Codex,
|
||||
* OpenAI Responses) send it as `service_tier`; other providers ignore it.
|
||||
* "priority" is Codex fast mode (1.5x speed, ~2x cost).
|
||||
*/
|
||||
serviceTier?: ServiceTier | null;
|
||||
/**
|
||||
* Provider-scoped environment values. These take precedence over process.env for
|
||||
* provider configuration such as regional settings, endpoint placeholders, and
|
||||
@@ -773,6 +786,12 @@ export interface Model<TApi extends Api> {
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Optional default request-level service tier for this model (e.g. "priority"
|
||||
* for Codex fast mode). Configured via models.json; forwarded to providers
|
||||
* that support `service_tier`.
|
||||
*/
|
||||
serviceTier?: ServiceTier;
|
||||
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
|
||||
compat?: TApi extends "openai-completions"
|
||||
? OpenAICompletionsCompat
|
||||
|
||||
@@ -151,6 +151,13 @@ const ModelCostSchema = Type.Object({
|
||||
tiers: Type.Optional(Type.Array(ModelCostTierSchema)),
|
||||
});
|
||||
|
||||
const ServiceTierSchema = Type.Union([
|
||||
Type.Literal("auto"),
|
||||
Type.Literal("default"),
|
||||
Type.Literal("priority"),
|
||||
Type.Literal("flex"),
|
||||
]);
|
||||
|
||||
const ModelDefinitionSchema = Type.Object({
|
||||
id: Type.String({ minLength: 1 }),
|
||||
name: Type.Optional(Type.String({ minLength: 1 })),
|
||||
@@ -163,6 +170,7 @@ const ModelDefinitionSchema = Type.Object({
|
||||
contextWindow: Type.Optional(Type.Number()),
|
||||
maxTokens: Type.Optional(Type.Number()),
|
||||
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
serviceTier: Type.Optional(ServiceTierSchema),
|
||||
compat: Type.Optional(ProviderCompatSchema),
|
||||
});
|
||||
|
||||
@@ -183,6 +191,7 @@ const ModelOverrideSchema = Type.Object({
|
||||
contextWindow: Type.Optional(Type.Number()),
|
||||
maxTokens: Type.Optional(Type.Number()),
|
||||
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
serviceTier: Type.Optional(ServiceTierSchema),
|
||||
compat: Type.Optional(ProviderCompatSchema),
|
||||
});
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ function applyModelOverride(model: Model<Api>, override: ModelsJsonModelOverride
|
||||
: model.cost,
|
||||
contextWindow: override.contextWindow ?? model.contextWindow,
|
||||
maxTokens: override.maxTokens ?? model.maxTokens,
|
||||
serviceTier: override.serviceTier ?? model.serviceTier,
|
||||
compat: mergeCompat(model.compat, override.compat),
|
||||
};
|
||||
}
|
||||
@@ -154,6 +155,7 @@ function modelFromJson(
|
||||
contextWindow: definition.contextWindow ?? 128000,
|
||||
maxTokens: definition.maxTokens ?? 16384,
|
||||
headers: undefined,
|
||||
serviceTier: definition.serviceTier,
|
||||
compat: mergeCompat(providerConfig.compat, definition.compat),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,8 +309,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const websocketConnectTimeoutMs =
|
||||
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
|
||||
const headerRunner = extensionRunnerRef.current;
|
||||
// Model-level service tier (models.json `serviceTier`, e.g. "priority" for
|
||||
// Codex fast mode) is forwarded as request `service_tier`. Providers that
|
||||
// don't understand it ignore the option.
|
||||
const serviceTier = model.serviceTier;
|
||||
return modelRuntime.streamSimple(model, context, {
|
||||
...options,
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
timeoutMs,
|
||||
websocketConnectTimeoutMs,
|
||||
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
|
||||
|
||||
@@ -4,12 +4,17 @@ This directory is the version-controlled resource layer for the integrated app.
|
||||
|
||||
- `skills/` contains authored or reviewed skills.
|
||||
- `extensions/` contains Pi extensions.
|
||||
- `packages/` contains repository-local Pi packages, including the repaired `pi-smart-fetch` build.
|
||||
- `prompts/` contains prompt templates.
|
||||
- `themes/` contains custom Pi themes.
|
||||
|
||||
The managed launcher loads these directories for every workspace. Project-local
|
||||
`.pi` and `.agents` resources from an opened workspace are not loaded.
|
||||
|
||||
The local `resources/packages/pi-smart-fetch` package is configured through
|
||||
`config/settings.default.json`; setup installs its runtime dependencies and
|
||||
migrates older `npm:pi-smart-fetch` profile entries automatically.
|
||||
|
||||
## Included extensions
|
||||
|
||||
- `extensions/package.json` is the explicit Pi extension manifest. Add future
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Thinkscape
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,114 @@
|
||||
# pi-smart-fetch (project-local repair)
|
||||
|
||||
This vendored build is based on `pi-smart-fetch@0.3.17` and adds the ESM/CommonJS compatibility shim required by this project's Node/Next.js runtime. The bundled CommonJS dependencies receive a real `require` from `createRequire(import.meta.url)`, so loading the extension no longer fails with `Dynamic require of "path" is not supported`.
|
||||
|
||||
`pi-smart-fetch` adds smarter web fetching tools to pi.dev.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Browser-like TLS/SSL + HTTP fingerprints** — better success on bot-defended pages
|
||||
- 🧹 **Defuddle extraction** — clean readable content instead of noisy HTML
|
||||
- 🧠 **Useful metadata** — title, author, site, language, published date when available
|
||||
- 📦 **Downloads + large file support** — stream attachments and binaries to temp files
|
||||
- 🔁 **Client-side `<meta>` redirects** — follows sane meta refresh redirects with loop limits
|
||||
- 🔗 **Alternate content fallback** — when extraction produces no/thin content, follows qualified `<link rel="alternate" type="...">` entries in `<head>` that match the requested output format
|
||||
- ⚡ **Batch fetch** — fetch many URLs with bounded concurrency
|
||||
- 📝 **Multiple output formats** — `markdown`, `html`, `text`, `json`, `raw`
|
||||
|
||||
## Site optimisations
|
||||
|
||||
This package works on general web pages, but some site types benefit especially from Defuddle's extractors and cleanup:
|
||||
|
||||
- YouTube pages and transcripts
|
||||
- Reddit posts and comment threads
|
||||
- X / Twitter posts
|
||||
- GitHub pages, issues, PRs, and discussions
|
||||
- Hacker News threads
|
||||
- Substack posts
|
||||
- Pages with code blocks, footnotes, math, and callouts
|
||||
|
||||
Notes:
|
||||
|
||||
- Defuddle is the cleanup layer: it strips common page chrome like nav, sidebars, related links, share widgets, and footers
|
||||
- It does **not** execute JavaScript or solve interactive anti-bot/login flows
|
||||
- If an HTML shell advertises alternate content in `<head>`, smart-fetch can follow matching alternates such as `text/markdown`, `text/plain`, `text/html`, or JSON media types according to the requested `format`
|
||||
|
||||
## Install
|
||||
|
||||
This repository loads the repaired package automatically from
|
||||
`resources/packages/pi-smart-fetch`. The setup script installs its runtime
|
||||
dependencies into that package directory; `config/settings.default.json`
|
||||
points Pi at the local package instead of the broken npm build.
|
||||
|
||||
The original upstream package can still be installed separately when needed:
|
||||
|
||||
```bash
|
||||
pi install npm:pi-smart-fetch
|
||||
```
|
||||
|
||||
## Pi tools
|
||||
|
||||
Registers:
|
||||
|
||||
- `web_fetch`
|
||||
- `batch_web_fetch`
|
||||
|
||||
Synopsis:
|
||||
|
||||
```text
|
||||
web_fetch(url, browser?, os?, headers?, maxChars?, timeoutMs?, format?, removeImages?, includeReplies?, proxy?, verbose?)
|
||||
batch_web_fetch(requests, verbose?)
|
||||
```
|
||||
|
||||
For `batch_web_fetch`, each item in `requests` accepts the same parameters as `web_fetch` except `verbose`.
|
||||
|
||||
## Output formats
|
||||
|
||||
| Format | What you get |
|
||||
| --- | --- |
|
||||
| `markdown` | Best default for readable page content |
|
||||
| `html` | Cleaned HTML output |
|
||||
| `text` | Plain text with markdown stripped |
|
||||
| `json` | Structured JSON for metadata-heavy workflows |
|
||||
| `raw` | Full raw server response without extraction or truncation — for further parsing |
|
||||
|
||||
## Global defaults
|
||||
|
||||
Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"smartFetchVerboseByDefault": false,
|
||||
"smartFetchDefaultMaxChars": 50000,
|
||||
"smartFetchDefaultTimeoutMs": 15000,
|
||||
"smartFetchDefaultBrowser": "chrome_145",
|
||||
"smartFetchDefaultOs": "windows",
|
||||
"smartFetchDefaultRemoveImages": false,
|
||||
"smartFetchDefaultIncludeReplies": "extractors",
|
||||
"smartFetchDefaultBatchConcurrency": 8,
|
||||
"smartFetchTempDir": "/tmp/smart-fetch-pi"
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Default | Description |
|
||||
| --- | ---: | --- |
|
||||
| `smartFetchVerboseByDefault` | `false` | Stored default for the compatibility `verbose` flag |
|
||||
| `smartFetchDefaultMaxChars` | `50000` | Default `maxChars` limit |
|
||||
| `smartFetchDefaultTimeoutMs` | `15000` | Default request timeout in milliseconds |
|
||||
| `smartFetchDefaultBrowser` | `chrome_145` | Default browser fingerprint profile |
|
||||
| `smartFetchDefaultOs` | `windows` | Default OS fingerprint profile |
|
||||
| `smartFetchDefaultRemoveImages` | `false` | Strip image references by default |
|
||||
| `smartFetchDefaultIncludeReplies` | `extractors` | Include replies/comments only when site extractors support them |
|
||||
| `smartFetchDefaultBatchConcurrency` | `8` | Default bounded concurrency for `batch_web_fetch` |
|
||||
| `smartFetchTempDir` | OS temp dir | Base directory for attachment and binary downloads |
|
||||
|
||||
Notes:
|
||||
|
||||
- Project `.pi/settings.json` overrides global `~/.pi/agent/settings.json`
|
||||
- Legacy `webFetch*` aliases are still supported
|
||||
|
||||
## Dev and publishing note
|
||||
|
||||
This repo uses Bun for local development, tests, and workspace scripts. Package publishing still goes through `npm publish` in CI so npm Trusted Publishing can be used.
|
||||
+12412
File diff suppressed because it is too large
Load Diff
+469
@@ -0,0 +1,469 @@
|
||||
{
|
||||
"name": "pi-smart-fetch-project-fix",
|
||||
"version": "0.3.17-project.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-smart-fetch-project-fix",
|
||||
"version": "0.3.17-project.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "^0.82.1",
|
||||
"@sinclair/typebox": "^0.34.52",
|
||||
"defuddle": "^0.19.2",
|
||||
"linkedom": "^0.18.13",
|
||||
"mime-types": "^3.0.2",
|
||||
"wreq-js": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz",
|
||||
"integrity": "sha512-9yN8hALfKaxZq7n54EMxqhFCWnMi6LHkraMJ/1YjHiATq75XrI6XDMVppn9EDtiK7Fks8hUe1SDXUTrIvwRWfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"marked": "18.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.52",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz",
|
||||
"integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
|
||||
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
|
||||
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^2.0.0",
|
||||
"css-what": "^8.0.0",
|
||||
"domhandler": "^6.0.1",
|
||||
"domutils": "^4.0.2",
|
||||
"nth-check": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
|
||||
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cssom": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
|
||||
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/defuddle": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/defuddle/-/defuddle-0.19.2.tgz",
|
||||
"integrity": "sha512-FY+a9Byo3uS04SQnQjLaguNoEA0Y58vXTB1XvvnOkO6M0zo897ipekv2Unci42QEY70emZ6OLe7C0vPojr1JEA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"defuddle": "dist/cli.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"linkedom": "^0.18.12",
|
||||
"mathml-to-latex": "^1.8.0",
|
||||
"temml": "^0.13.3",
|
||||
"turndown": "^7.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
|
||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"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/html-escaper": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
||||
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/linkedom": {
|
||||
"version": "0.18.13",
|
||||
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",
|
||||
"integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"css-select": "^7.0.0",
|
||||
"cssom": "^0.5.0",
|
||||
"html-escaper": "^3.0.3",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"uhyphen": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": ">= 2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mathml-to-latex": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mathml-to-latex/-/mathml-to-latex-1.8.0.tgz",
|
||||
"integrity": "sha512-gQ0uK3zqB8HwlfaXJkEL5rgaZNbKUiBMmBP/B/W+b+t6KcseLSuYb1b0BjLgS9ZiQa24ePkqTX8/6FaQuDL7wQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
|
||||
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/temml": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/temml/-/temml-0.13.4.tgz",
|
||||
"integrity": "sha512-k1yolMBswx34Jw9hZn5Xh2/GBlwqlW+wiN7QaUYUMhSa1ypfti6rlCfSmCxWyooxH1G32C/Z+qVvgAsBOELZBQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"npm": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/uhyphen": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
|
||||
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wreq-js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wreq-js/-/wreq-js-2.3.1.tgz",
|
||||
"integrity": "sha512-vaKasaKeskrDKEuuO5Q5uamEG9a6FrF5ZSicH7TCvYS4RxF7/gzaU/vYqwJzcs+uydyJPVWY1KCvfVCgp0tiGA==",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "pi-smart-fetch-project-fix",
|
||||
"version": "0.3.17-project.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Project-local repaired build of pi-smart-fetch with ESM/CommonJS compatibility.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Thinkscape/agent-smart-fetch"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "^0.82.1",
|
||||
"@sinclair/typebox": "^0.34.52",
|
||||
"defuddle": "^0.19.2",
|
||||
"linkedom": "^0.18.13",
|
||||
"mime-types": "^3.0.2",
|
||||
"wreq-js": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const webDir = join(rootDir, "pi-web");
|
||||
const serverDir = join(rootDir, "server");
|
||||
const requiredProfilePaths = [
|
||||
join(rootDir, "config", "settings.default.json"),
|
||||
join(rootDir, "config", "mcp.default.json"),
|
||||
@@ -37,7 +37,7 @@ function readJson(path) {
|
||||
}
|
||||
}
|
||||
|
||||
const webPackage = readJson(join(webDir, "package.json"));
|
||||
const serverPackage = readJson(join(serverDir, "package.json"));
|
||||
const failures = [];
|
||||
const versions = new Set();
|
||||
|
||||
@@ -47,13 +47,13 @@ for (const path of requiredProfilePaths) {
|
||||
|
||||
for (const [packageName, packageDirName] of packages) {
|
||||
const localDir = resolve(rootDir, "pi", "packages", packageDirName);
|
||||
const installedDir = resolve(webDir, "node_modules", ...packageName.split("/"));
|
||||
const installedDir = resolve(serverDir, "node_modules", ...packageName.split("/"));
|
||||
const localPackagePath = join(localDir, "package.json");
|
||||
const installedPackagePath = join(installedDir, "package.json");
|
||||
const expectedSpec = `file:../pi/packages/${packageDirName}`;
|
||||
|
||||
if (webPackage.dependencies?.[packageName] !== expectedSpec) {
|
||||
failures.push(`${packageName} must use ${expectedSpec}`);
|
||||
if (serverPackage.dependencies?.[packageName] !== expectedSpec) {
|
||||
failures.push(`${packageName} must use ${expectedSpec} in server/package.json`);
|
||||
continue;
|
||||
}
|
||||
if (!existsSync(localPackagePath)) {
|
||||
@@ -93,4 +93,4 @@ if (failures.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Local Pi backend packages are built and installed into Pi Web.");
|
||||
console.log("Local Pi backend packages are built and installed into Pi Agent Server.");
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Object.assign(process.env, managedEnvironment());
|
||||
|
||||
const codingAgentEntry = pathToFileURL(
|
||||
join(rootDir, "pi-web", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "index.js"),
|
||||
join(rootDir, "server", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "index.js"),
|
||||
).href;
|
||||
const { DefaultResourceLoader } = await import(codingAgentEntry);
|
||||
|
||||
|
||||
@@ -64,15 +64,16 @@ export function isPiAiVisionConfigured(source) {
|
||||
|
||||
export function defaultPiAiDir() {
|
||||
// Managed installs place packages under <agentDir>/npm/node_modules; pi-ai is
|
||||
// also a direct dependency of pi-web, which wins in dev. Prefer pi-web's copy.
|
||||
// also a direct dependency of the backend (server/), which wins at runtime.
|
||||
// Prefer the server's copy.
|
||||
const candidates = [
|
||||
resolve(process.cwd(), "pi-web", "node_modules", "@earendil-works", "pi-ai"),
|
||||
resolve(process.cwd(), "server", "node_modules", "@earendil-works", "pi-ai"),
|
||||
resolve(process.cwd(), "node_modules", "@earendil-works", "pi-ai"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(candidate, "package.json"))) return candidate;
|
||||
}
|
||||
throw new Error("pi-ai package not found under pi-web/node_modules or node_modules");
|
||||
throw new Error("pi-ai package not found under server/node_modules or node_modules");
|
||||
}
|
||||
|
||||
export function configurePiAiVision({ piAiDir = defaultPiAiDir(), quiet = false } = {}) {
|
||||
|
||||
@@ -1,16 +1,98 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { configurePiMemory } from "./configure-pi-memory.mjs";
|
||||
import { configurePiAiVision } from "./configure-pi-ai-vision.mjs";
|
||||
import { agentDir, managedEnvironment, rootDir } from "./profile.mjs";
|
||||
|
||||
const settings = JSON.parse(
|
||||
readFileSync(join(rootDir, "config", "settings.default.json"), "utf8"),
|
||||
);
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(join(rootDir, "config", "settings.default.json"), "utf8"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid config/settings.default.json: ${message}`);
|
||||
}
|
||||
const packages = Array.isArray(settings.packages) ? settings.packages : [];
|
||||
const cliPath = join(rootDir, "pi", "packages", "coding-agent", "dist", "cli.js");
|
||||
const env = managedEnvironment();
|
||||
const npmCliPath = process.env.npm_execpath;
|
||||
const localSmartFetchSource = "../../resources/packages/pi-smart-fetch";
|
||||
|
||||
function parseLocalSource(source) {
|
||||
if (
|
||||
source.startsWith("npm:") ||
|
||||
source.startsWith("git:") ||
|
||||
/^[a-z][a-z0-9+.-]*:\/\//i.test(source)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return resolve(agentDir, source);
|
||||
}
|
||||
|
||||
function packageDependenciesReady(packageDir) {
|
||||
const packageJsonPath = join(packageDir, "package.json");
|
||||
if (!existsSync(packageJsonPath)) return false;
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
const dependencies = packageJson.dependencies ?? {};
|
||||
return Object.keys(dependencies).every((name) =>
|
||||
existsSync(join(packageDir, "node_modules", ...name.split("/"), "package.json")),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLocalPackage(source) {
|
||||
const packageDir = parseLocalSource(source);
|
||||
if (!packageDir) return false;
|
||||
if (!existsSync(join(packageDir, "package.json"))) {
|
||||
throw new Error(`Local managed package is missing: ${packageDir}`);
|
||||
}
|
||||
if (packageDependenciesReady(packageDir)) {
|
||||
console.log(`Local managed package ready: ${source}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`Installing local managed package dependencies: ${source}`);
|
||||
const args = ["install", "--prefix", packageDir, "--omit=dev", "--legacy-peer-deps"];
|
||||
const result = npmCliPath
|
||||
? spawnSync(process.execPath, [npmCliPath, ...args], { cwd: rootDir, env, stdio: "inherit" })
|
||||
: spawnSync("npm", args, { cwd: rootDir, env, stdio: "inherit" });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureSmartFetchConfigured() {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
if (!existsSync(settingsPath)) return;
|
||||
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return;
|
||||
|
||||
const packages = Array.isArray(settings.packages) ? settings.packages : [];
|
||||
const migrated = packages.map((entry) =>
|
||||
entry === "npm:pi-smart-fetch" ||
|
||||
(typeof entry === "string" && entry.startsWith("npm:pi-smart-fetch@"))
|
||||
? localSmartFetchSource
|
||||
: entry,
|
||||
);
|
||||
if (!migrated.some((entry) => entry === localSmartFetchSource)) {
|
||||
migrated.push(localSmartFetchSource);
|
||||
}
|
||||
|
||||
if (JSON.stringify(packages) !== JSON.stringify(migrated)) {
|
||||
writeFileSync(settingsPath, `${JSON.stringify({ ...settings, packages: migrated }, null, 2)}\n`, "utf8");
|
||||
console.log(`Configured project-local smart fetch package: ${localSmartFetchSource}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseExactNpmSource(source) {
|
||||
if (!source.startsWith("npm:")) return undefined;
|
||||
@@ -32,8 +114,11 @@ function installedAtExpectedVersion(source) {
|
||||
}
|
||||
}
|
||||
|
||||
ensureSmartFetchConfigured();
|
||||
|
||||
for (const source of packages) {
|
||||
if (typeof source !== "string" || !source.trim()) continue;
|
||||
if (ensureLocalPackage(source)) continue;
|
||||
if (installedAtExpectedVersion(source)) {
|
||||
console.log(`Managed package ready: ${source}`);
|
||||
continue;
|
||||
|
||||
+54
-2
@@ -1,7 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { copyFileSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { loadEnvFile } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -55,6 +55,56 @@ const seedFiles = [
|
||||
];
|
||||
|
||||
const persistedWindowsEnvironmentKeys = ["SEARXNG_TOKEN", "SEARXNG_URL"];
|
||||
const serverBinDir = join(rootDir, "server", "node_modules", ".bin");
|
||||
const piCodingAgentPackageDir = join(
|
||||
rootDir,
|
||||
"server",
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
);
|
||||
const managedPeerPackageDir = join(
|
||||
agentDir,
|
||||
"npm",
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
);
|
||||
|
||||
function ensurePiSubagentRuntime() {
|
||||
// pi-subagents resolves the host CLI through its optional coding-agent peer.
|
||||
// The managed profile is installed separately from the backend, so expose the
|
||||
// existing local package instead of installing a second coding-agent copy.
|
||||
if (!existsSync(piCodingAgentPackageDir)) return;
|
||||
|
||||
mkdirSync(dirname(managedPeerPackageDir), { recursive: true });
|
||||
if (existsSync(managedPeerPackageDir)) return;
|
||||
try {
|
||||
if (lstatSync(managedPeerPackageDir)) return;
|
||||
} catch {
|
||||
// The peer link is absent; create it below.
|
||||
}
|
||||
|
||||
try {
|
||||
symlinkSync(
|
||||
piCodingAgentPackageDir,
|
||||
managedPeerPackageDir,
|
||||
platform() === "win32" ? "junction" : "dir",
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[subagents] could not link the local Pi CLI package: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function prependServerBinToPath(baseEnv) {
|
||||
if (!existsSync(serverBinDir)) return baseEnv.PATH ?? baseEnv.Path;
|
||||
const pathKey = baseEnv.PATH !== undefined && baseEnv.Path === undefined ? "PATH" : "Path";
|
||||
const currentPath = baseEnv[pathKey] ?? "";
|
||||
const entries = currentPath.split(delimiter).filter(Boolean);
|
||||
if (!entries.includes(serverBinDir)) entries.unshift(serverBinDir);
|
||||
return entries.join(delimiter);
|
||||
}
|
||||
|
||||
function readPersistedWindowsEnvironment(baseEnv) {
|
||||
if (platform() !== "win32") return {};
|
||||
@@ -112,10 +162,12 @@ export function ensureProfile({ quiet = false } = {}) {
|
||||
|
||||
export function managedEnvironment(baseEnv = process.env) {
|
||||
ensureProfile({ quiet: true });
|
||||
ensurePiSubagentRuntime();
|
||||
const persistedEnvironment = readPersistedWindowsEnvironment(baseEnv);
|
||||
return {
|
||||
...baseEnv,
|
||||
...persistedEnvironment,
|
||||
PATH: prependServerBinToPath({ ...baseEnv, ...persistedEnvironment }),
|
||||
PI_AGENT_MANAGED_RUNTIME: "1",
|
||||
PI_AGENT_APP_ROOT: rootDir,
|
||||
PI_AGENT_DATA_DIR: dataDir,
|
||||
|
||||
+121
-12
@@ -1,4 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { configurePiMemory } from "./configure-pi-memory.mjs";
|
||||
import { configurePiAiVision } from "./configure-pi-ai-vision.mjs";
|
||||
@@ -23,6 +24,7 @@ if (!npmCliPath) {
|
||||
}
|
||||
|
||||
const childEnvironment = managedEnvironment();
|
||||
const serverPort = Number(childEnvironment.PI_SERVER_PORT ?? "30142");
|
||||
const memoryConfiguration = configurePiMemory({ quiet: true });
|
||||
if (memoryConfiguration.status === "missing") {
|
||||
console.warn("[memory] pi-memory is not installed; run npm run setup to enable managed memory");
|
||||
@@ -38,28 +40,135 @@ if (await isLocalPortListening(30141)) {
|
||||
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
const children = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
function spawnPackageScript(packageDir, script) {
|
||||
return spawn(
|
||||
process.execPath,
|
||||
[npmCliPath, "--prefix", join(rootDir, "pi-web"), "run", target],
|
||||
[npmCliPath, "--prefix", join(rootDir, packageDir), "run", script],
|
||||
{
|
||||
cwd: rootDir,
|
||||
env: childEnvironment,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => child.kill(signal));
|
||||
);
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
function killChild(child, signal) {
|
||||
if (child.killed || child.exitCode !== null) return;
|
||||
if (platform() === "win32") {
|
||||
// Windows has no process groups: killing the npm wrapper leaves the
|
||||
// real workers (tsx watch / next dev) orphaned and holding their ports.
|
||||
// taskkill /T terminates the whole tree instead.
|
||||
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAll(signal) {
|
||||
shuttingDown = true;
|
||||
for (const child of children) {
|
||||
killChild(child, signal);
|
||||
}
|
||||
}
|
||||
|
||||
// TCP listening alone is not enough: an orphaned backend from a previous run
|
||||
// can hold the port. Verify identity through the health endpoint instead.
|
||||
async function isBackendHealthy() {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, {
|
||||
signal: AbortSignal.timeout(750),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const body = await response.json();
|
||||
return body?.name === "pi-agent-server";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => stopAll(signal));
|
||||
}
|
||||
|
||||
if (target === "build") {
|
||||
// Sequential builds: the backend is independent, the frontend only needs
|
||||
// the shared types package, so order matters only for readable logs.
|
||||
const serverBuild = spawnPackageScript("server", "build");
|
||||
serverBuild.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!signal && code === 0 && target === "build") {
|
||||
});
|
||||
serverBuild.on("exit", (code, signal) => {
|
||||
if (signal || code !== 0) process.exit(code ?? 1);
|
||||
const webBuild = spawnPackageScript("pi-web", "build");
|
||||
webBuild.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
webBuild.on("exit", (code, signal) => {
|
||||
if (!signal && code === 0) {
|
||||
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
|
||||
}
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Backend first: agent sessions live in pi-agent-server, and Pi Web's
|
||||
// /api rewrites need it listening before the first request arrives.
|
||||
if (await isLocalPortListening(serverPort)) {
|
||||
console.error(
|
||||
`[run] port ${serverPort} is already in use (possibly an orphaned backend). ` +
|
||||
`Find it with: netstat -ano | findstr :${serverPort}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const serverScript = target.startsWith("dev") ? "dev" : "start";
|
||||
const server = spawnPackageScript("server", serverScript);
|
||||
children.push(server);
|
||||
server.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
server.on("exit", (code, signal) => {
|
||||
if (shuttingDown) return;
|
||||
console.error(`[run] backend exited (${code ?? signal}); shutting down Pi Web`);
|
||||
stopAll(signal ?? "SIGTERM");
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
const startWeb = () => {
|
||||
const web = spawnPackageScript("pi-web", target);
|
||||
children.push(web);
|
||||
web.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
web.on("exit", (code, signal) => {
|
||||
stopAll(signal ?? "SIGTERM");
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
};
|
||||
|
||||
let webStarted = false;
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (server.exitCode !== null || server.killed) break;
|
||||
if (await isBackendHealthy()) {
|
||||
webStarted = true;
|
||||
startWeb();
|
||||
break;
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
||||
}
|
||||
if (!webStarted) {
|
||||
console.error(`[run] backend did not become ready on port ${serverPort} within 30s`);
|
||||
stopAll("SIGTERM");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
@@ -91,17 +91,20 @@ function assertSupportedNodeVersion() {
|
||||
assertSupportedNodeVersion();
|
||||
ensureProfile();
|
||||
|
||||
console.log("[1/5] Installing Pi dependencies...");
|
||||
console.log("[1/6] Installing Pi dependencies...");
|
||||
run(["ci", "--ignore-scripts"], join(rootDir, "pi"));
|
||||
|
||||
console.log("[2/5] Hydrating model data and building local Pi packages...");
|
||||
console.log("[2/6] Hydrating model data and building local Pi packages...");
|
||||
hydrateModelData();
|
||||
run(["run", "build:offline"], join(rootDir, "pi"));
|
||||
|
||||
console.log("[3/5] Installing Pi Web with local Pi package links...");
|
||||
run(["ci", "--ignore-scripts", "--install-links"], join(rootDir, "pi-web"));
|
||||
console.log("[3/6] Installing Pi Web dependencies...");
|
||||
run(["ci", "--ignore-scripts"], join(rootDir, "pi-web"));
|
||||
|
||||
console.log("[4/5] Installing and validating managed Pi packages...");
|
||||
console.log("[4/6] Installing Pi Agent Server with local Pi package links...");
|
||||
run(["ci", "--ignore-scripts", "--install-links"], join(rootDir, "server"));
|
||||
|
||||
console.log("[5/6] Installing and validating managed Pi packages...");
|
||||
const packageResult = spawnSync(process.execPath, [join(rootDir, "scripts", "install-managed-packages.mjs")], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
@@ -118,7 +121,7 @@ const profileResult = spawnSync(process.execPath, [join(rootDir, "scripts", "che
|
||||
if (profileResult.error) throw profileResult.error;
|
||||
if (profileResult.status !== 0) process.exit(profileResult.status ?? 1);
|
||||
|
||||
console.log("[5/5] Verifying the integration...");
|
||||
console.log("[6/6] Verifying the integration...");
|
||||
const checkResult = spawnSync(process.execPath, [join(rootDir, "scripts", "check-integration.mjs")], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
Generated
+2565
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "pi-agent-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Standalone backend for Pi Agent Integrated",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "file:../pi/packages/agent",
|
||||
"@earendil-works/pi-ai": "file:../pi/packages/ai",
|
||||
"@earendil-works/pi-coding-agent": "file:../pi/packages/coding-agent",
|
||||
"@earendil-works/pi-tui": "file:../pi/packages/tui",
|
||||
"@hono/node-server": "^1.19.3",
|
||||
"hono": "^4.11.4",
|
||||
"mammoth": "^1.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25",
|
||||
"jiti": "^2.7.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { registerAgentRoutes } from "./routes/agent.js";
|
||||
import { registerAuthRoutes } from "./routes/auth.js";
|
||||
import { registerConfigRoutes } from "./routes/config.js";
|
||||
import { registerFileRoutes } from "./routes/files.js";
|
||||
import { registerGitAndModelRoutes } from "./routes/models.js";
|
||||
import { registerMiscRoutes } from "./routes/misc.js";
|
||||
import { registerSessionRoutes } from "./routes/sessions.js";
|
||||
import { registerSkillRoutes } from "./routes/skills.js";
|
||||
import { securityMiddleware } from "./security.js";
|
||||
|
||||
const serverDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const { version } = JSON.parse(readFileSync(join(serverDir, "package.json"), "utf8")) as { version: string };
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
export const app = new Hono();
|
||||
|
||||
app.use("/api/*", securityMiddleware);
|
||||
|
||||
// Liveness probe used by scripts/run.mjs to sequence startup and by
|
||||
// npm run check to verify the backend is reachable.
|
||||
app.get("/api/health", (c) =>
|
||||
c.json({
|
||||
status: "ok",
|
||||
name: "pi-agent-server",
|
||||
version,
|
||||
uptimeMs: Date.now() - startedAt,
|
||||
}),
|
||||
);
|
||||
|
||||
registerAgentRoutes(app);
|
||||
registerSessionRoutes(app);
|
||||
registerFileRoutes(app);
|
||||
registerGitAndModelRoutes(app);
|
||||
registerMiscRoutes(app);
|
||||
registerAuthRoutes(app);
|
||||
registerConfigRoutes(app);
|
||||
registerSkillRoutes(app);
|
||||
|
||||
export function startServer(): ReturnType<typeof serve> {
|
||||
const port = Number(process.env.PI_SERVER_PORT ?? "30142");
|
||||
// The frontend proxies /api here server-side, so the backend stays on
|
||||
// loopback even in LAN mode unless the operator overrides explicitly.
|
||||
const hostname = process.env.PI_SERVER_HOSTNAME ?? "127.0.0.1";
|
||||
|
||||
const server = serve({ fetch: app.fetch, port, hostname }, (info) => {
|
||||
console.log(`[server] pi-agent-server ${version} listening on http://${info.address}:${info.port}`);
|
||||
});
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
console.log(`[server] received ${signal}, shutting down`);
|
||||
server.close(() => process.exit(0));
|
||||
// Force-exit if lingering connections (e.g. open SSE streams) block close.
|
||||
setTimeout(() => process.exit(0), 3000).unref();
|
||||
};
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
startServer();
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface SkillSearchResult {
|
||||
package: string;
|
||||
installs: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type SkillInstallScope = "global" | "project";
|
||||
|
||||
export interface SkillInstallInfo {
|
||||
package: string;
|
||||
scope: SkillInstallScope;
|
||||
source: string;
|
||||
sourceType?: string;
|
||||
skillsShUrl?: string;
|
||||
skillPath?: string;
|
||||
ref?: string;
|
||||
versionHash?: string;
|
||||
canCheckForUpdates: boolean;
|
||||
}
|
||||
|
||||
export type SkillUpdateState =
|
||||
| "up-to-date"
|
||||
| "update-available"
|
||||
| "unsupported"
|
||||
| "error";
|
||||
|
||||
export interface SkillUpdateResult {
|
||||
package: string;
|
||||
scope: SkillInstallScope;
|
||||
state: SkillUpdateState;
|
||||
currentVersion?: string;
|
||||
latestVersion?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
disableModelInvocation: boolean;
|
||||
sourceInfo: {
|
||||
source?: string;
|
||||
scope?: string;
|
||||
};
|
||||
install?: SkillInstallInfo;
|
||||
}
|
||||
|
||||
export interface SkillsResponse {
|
||||
skills: SkillInfo[];
|
||||
diagnostics: ResourceDiagnostic[];
|
||||
projectResourcesLoaded: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectTrustStatus {
|
||||
requiresTrust: boolean;
|
||||
trusted: boolean;
|
||||
}
|
||||
|
||||
export type PluginScope = "global" | "project";
|
||||
export type PluginResourceKind = "extension" | "skill" | "prompt" | "theme";
|
||||
|
||||
export interface PluginResourceCounts {
|
||||
extensions: number;
|
||||
skills: number;
|
||||
prompts: number;
|
||||
themes: number;
|
||||
}
|
||||
|
||||
export interface PluginDiagnostic {
|
||||
type: "warning" | "error";
|
||||
message: string;
|
||||
source?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface PluginResourceInfo {
|
||||
kind: PluginResourceKind;
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageInfo {
|
||||
source: string;
|
||||
scope: PluginScope;
|
||||
filtered: boolean;
|
||||
disabled: boolean;
|
||||
installedPath?: string;
|
||||
packageName?: string;
|
||||
version?: string;
|
||||
configuredVersion?: string;
|
||||
counts: PluginResourceCounts;
|
||||
resources: PluginResourceInfo[];
|
||||
status: "loaded" | "installed" | "missing" | "disabled";
|
||||
}
|
||||
|
||||
export interface PluginsResponse {
|
||||
packages: PluginPackageInfo[];
|
||||
totals: PluginResourceCounts;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
projectResourcesLoaded: boolean;
|
||||
}
|
||||
|
||||
export type ExtensionScope = "global" | "project" | "builtin";
|
||||
export type ExtensionStatus = "enabled" | "disabled" | "blocked";
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
source: string;
|
||||
scope: ExtensionScope;
|
||||
status: ExtensionStatus;
|
||||
}
|
||||
|
||||
export interface ExtensionsResponse {
|
||||
extensions: ExtensionInfo[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
projectResourcesLoaded: boolean;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -39,7 +39,6 @@ test("reads small output and rejects oversized inline output before buffering it
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects symbolic links when opening bash output", async (t) => {
|
||||
const { readUtf8FileWithinLimit } = await loadSubject();
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-bash-output-link-"));
|
||||
@@ -8,17 +8,27 @@ async function loadSubject() {
|
||||
return import("./directory-browser.ts");
|
||||
}
|
||||
|
||||
test("lists directories and directory symlinks without returning files", async () => {
|
||||
test("lists directories and directory symlinks without returning files", async (t) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "pi-web-browse-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "project"));
|
||||
await writeFile(path.join(root, "notes.txt"), "test", "utf8");
|
||||
let symlinkCreated = false;
|
||||
try {
|
||||
await symlink(path.join(root, "project"), path.join(root, "linked-project"));
|
||||
symlinkCreated = true;
|
||||
} catch (error) {
|
||||
if (error?.code !== "EPERM") throw error;
|
||||
t.diagnostic("Creating symbolic links requires additional privileges on this platform");
|
||||
}
|
||||
|
||||
const { listDirectories } = await loadSubject();
|
||||
const directories = await listDirectories(root);
|
||||
|
||||
assert.deepEqual(directories.map((entry) => entry.name), ["linked-project", "project"]);
|
||||
assert.deepEqual(
|
||||
directories.map((entry) => entry.name),
|
||||
symlinkCreated ? ["linked-project", "project"] : ["project"],
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export function normalizeDirectory(directory: string): string {
|
||||
export function getParentDirectory(directory: string): string | null {
|
||||
const pathApi = /^[a-zA-Z]:[\\/]/.test(directory) || directory.startsWith("\\\\")
|
||||
? path.win32
|
||||
: directory.startsWith("/")
|
||||
? path.posix
|
||||
: path;
|
||||
const normalized = pathApi.normalize(directory);
|
||||
const parent = pathApi.dirname(normalized);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user