我把 pi-web 的源码扒了一遍,逐个端点读过并实测,整理成这份手册。目的不是给 pi-web 提 PR,而是让我自己以后能绕过它的界面直接调——比如写脚本问"现在有多少对话在跑"、把答案扔回某个对话继续跑、或者拿会话状态做自己的看板。
以 pi-web
0.8.11为准。凡标「实测」的是我真发过请求验证过的;标「读源码」的是没在真实数据上跑写操作、只按代码推的。这份东西会过期,pi-web 更新很快(写这篇时 npm 上已到0.9.1)。
一、它怎么进得来#
pi-web 是本地工具,默认只监听 127.0.0.1:30141。可以用 --hostname 改绑到别的地址(比如容器网桥,或 0.0.0.0 走局域网)——改了之后 127.0.0.1:30141 就连不上了,得用你实际绑定的那个地址访问。
请求进门要过两道闸,顺序是先信源、后密码(proxy.ts):
| 闸 | 规则 | 不通过的后果 |
|---|---|---|
| Host 白名单 | localhost、*.localhost、任意 IP 字面量直接放行;其余必须精确等于 PI_WEB_HOSTNAME 或 PI_WEB_ALLOWED_HOSTS 里的值 | 403 {"error":"Untrusted API request"} |
| Origin / Sec-Fetch-Site | 只在请求带 Origin 或 Sec-Fetch-Site 时才检查;cross-site 直接拒;带 Origin 就必须与 Host 同源 | 403 |
| 密码 | 见下 | 页面请求 302 到 /login;API 请求 401 {"error":"Authentication required"} |
密码那层有两种凭证,任选其一即可:
Authorization: Basic base64("pi:<密码>")—— 用户名固定是pi,脚本走这条。Cookie: pi-web-auth=<值>—— 浏览器登录后拿到,有效期 7 天。
免鉴权的路径只有三个:/login、/api/auth/web/login、/api/auth/web/logout。其余全部要密码,包括 /api/auth/providers 这种看起来无关紧要的。
API 未授权时返回的 401 故意不带 WWW-Authenticate(源码注释说是为了避免 Chrome 弹出原生 Basic 对话框并扣住 fetch 响应)——所以浏览器不会弹框,只会看到 401。
二、外部脚本接入的正确姿势#
前提是知道启动 pi-web 时设的 PI_WEB_PASSWORD(它就是普通环境变量,怎么存取决于你怎么起服务;别把它写进任何会被别人读到的地方)。然后记住四条:
Host 要对得上:连 IP 字面量随便连;用域名访问时该域名必须在
PI_WEB_ALLOWED_HOSTS里。浏览器改不了 Host,但curl -H 'Host: ...'能——别乱改。别带
Origin,也别带Sec-Fetch-Site。带了就要和 Host 同源,否则 403。Node 的fetch、axios、curl默认都不带,直接用就对了——这就是"裸脚本天然免过第二闸"的原因。写操作带
Content-Type: application/json,并且是合法 JSON。部分端点会强校验(不合法给 415),也有几个端点忘了 catch 非法 JSON,会返回 500 而不是 400。SSE 用
curl -N,反代还要关缓冲。
最省事的写法:
# 自己先 export 一下这两个变量,全文示例都用它们
export BASE=http://127.0.0.1:30141
export PW="<启动 pi-web 时设的 PI_WEB_PASSWORD>"
curl -s -u "pi:$PW" "$BASE/api/sessions" | head -c 300Node 侧同理:
const r = await fetch(`${BASE}/api/sessions`, {
headers: { Authorization: 'Basic ' + Buffer.from(`pi:${process.env.PI_WEB_PASSWORD}`).toString('base64') },不用先换 cookie:脚本每次都做 Basic Auth 就够了。cookie 的唯一价值是让浏览器免输密码。
三、心智模型:47 个路由,7 组#
先把最容易混的四个区分清楚:
| 你想知道 | 该调谁 | 说明 |
|---|---|---|
| 历史上有哪些对话 | GET /api/sessions | 读磁盘,返回全部会话 + runningSessionIds。默认走 30 秒内存缓存 |
| 现在谁在跑(最省) | GET /api/agent/running | 只读进程内注册表,不碰磁盘、不产生 agent 命令,适合 1–3 秒轮询 |
| 某个对话现在什么状态 | GET /api/sessions/[id]/state | 轻量;和 POST /api/agent/[id] {"type":"get_state"} 返回同一份结构 |
| 怎么给它发消息 | POST /api/agent/[id],{"type":"prompt","message":...} | 这是「把答案抛回对话」的唯一入口 |
七组的分工:
| 组 | 路由文件 | 干什么 |
|---|---|---|
| 会话 sessions | 8 | 读历史、看状态、重命名、删除、导出 |
| 对话 agent | 5 | 新建会话、发命令、订阅实时流 |
| 鉴权 auth | 6 | provider 登录、API Key、网页登录 |
| 模型与资源 | 7 | 模型列表、models.json 读写、插件装卸、工具设置 |
| 文件与 Git | 8 | 浏览/读写/上传文件、文件索引、Git 变更、worktree |
| 技能与推送 | 9 | skills 搜索/装/更新、Web Push、版本检查、home |
| 子代理与信任 | 4 | subagent profile CRUD、项目信任 |
四、端点速查表#
47 个路由文件、65 个方法导出(一个文件里 GET/POST/PATCH/DELETE 会有多个),下面按方法拆开列出。这些端点全部需要认证,故不再单列。加粗的是最常用的。
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /api/sessions | 列出全部会话(磁盘 + 运行中),带 runningSessionIds |
| GET | /api/sessions/[id] | 单个会话详情:树、上下文、用量统计、活跃时长 |
| PATCH | /api/sessions/[id] | 重命名(body {name}) |
| DELETE | /api/sessions/[id] | 删除会话文件(不可撤销) |
| GET | /api/sessions/[id]/state | 运行时状态(在跑吗、上下文占用多少) |
| GET | /api/sessions/[id]/context | 分页取上下文消息(向上翻页用) |
| GET | /api/sessions/[id]/export | 导出成自包含单文件 HTML |
| POST | /api/sessions/[id]/auto-name | 用模型生成会话标题(会真调一次模型) |
| GET | /api/sessions/[id]/entries/[entryId]/thinking | 懒加载某个 thinking 块 |
| GET | /api/sessions/[id]/entries/[entryId]/tool-result-image | 懒加载工具结果里的图片 |
| POST | /api/agent/new | 新建会话并下发第一条消息 |
| POST | /api/agent/[id] | 给已存在的会话发命令(prompt / abort / set_model / fork …) |
| GET | /api/agent/[id] | 只读状态快照,不启动会话 |
| GET | /api/agent/[id]/events | SSE 实时事件流,会冷启动会话 |
| GET | /api/agent/running | 正在跑的会话 id 快照 |
| GET | /api/agent/[id]/bash-output | 读 bash 长输出的临时文件 |
| GET | /api/auth/providers | 可登录 provider 清单(OAuth / API Key) |
| GET | /api/auth/login/[provider] | 启动 OAuth 登录(SSE 推交互步骤) |
| POST | /api/auth/login/[provider] | 回填授权码(body {token,code}) |
| POST | /api/auth/logout/[provider] | 删 OAuth 凭据 |
| POST | /api/auth/api-key/[provider] | 存 API Key(body {apiKey}) |
| DELETE | /api/auth/api-key/[provider] | 删 API Key |
| POST | /api/auth/web/login | 用密码换 7 天 cookie |
| POST | /api/auth/web/logout | 清 cookie |
| GET | /api/models | 按 cwd 列可用模型 + thinking 档位 |
| GET | /api/models-config | 明文读 models.json |
| PUT | /api/models-config | 全量覆盖 models.json |
| GET | /api/models-config/catalog | 从 models.dev 搜模型元数据与价格 |
| POST | /api/models-config/discover | 问上游「有哪些模型」 |
| POST | /api/models-config/test | 发一条真实请求测模型通不通 |
| GET | /api/plugins | 列插件包与加载出的资源 |
| POST | /api/plugins | 装/卸/更新/启停插件包 |
| GET | /api/tools/settings | 是否 Windows / PowerShell 工具开关 |
| PUT | /api/tools/settings | 切 PowerShell 工具(仅 Windows) |
| GET | /api/files/[...path] | 列目录/读文件/下载/预览/监听(?type=) |
| POST | /api/files/[...path] | 上传 / 上传冲突预检 |
| GET | /api/file-index | 文件模糊搜索(@ 补全用) |
| GET | /api/cwd/browse | 列子目录(目录选择器) |
| POST | /api/cwd/validate | 校验目录并授权它 |
| POST | /api/default-cwd | 建并返回 ~/pi-cwd-YYYYMMDD |
| GET | /api/git/status | 变更文件 + 增删行数 |
| GET | /api/git/diff | 单文件 unified diff |
| GET | /api/worktrees | 列 worktree |
| POST | /api/worktrees | 新建 worktree |
| DELETE | /api/worktrees | 删除 worktree(body 传参) |
| GET | /api/skills | 列某 cwd 下可用的全部 skill |
| PATCH | /api/skills | 切 skill 的 disable-model-invocation |
| POST | /api/skills/check | 检查 skill 有无新版本 |
| POST | /api/skills/install | 装 skill(底层 npx skills add) |
| POST | /api/skills/search | 搜 skills 市场 |
| POST | /api/skills/update | 更新已装 skill |
| GET | /api/push/config | 取 Web Push 公钥 |
| POST | /api/push/subscribe | 登记浏览器推送订阅 |
| GET | /api/app-update | 查 pi-web 自身有无新版本 |
| GET | /api/home | 服务端的 home 目录 |
| GET | /api/subagents/[id] | 子代理运行元信息 |
| POST | /api/subagents/[id] | steer / abort 子代理 |
| GET | /api/subagents/profiles | 列全部子代理 profile |
| PUT | /api/subagents/profiles | 新建/覆盖 profile |
| PATCH | /api/subagents/profiles | 切 profile 的 enabled |
| DELETE | /api/subagents/profiles | 删 profile(body 传参) |
| GET | /api/subagents/settings | 内置子代理扩展开关(本版本写死 false) |
| PUT | /api/subagents/settings | 写那个开关 |
| GET | /api/project-trust | 查某目录是否需要/已获得信任 |
| POST | /api/project-trust | 信任某项目(会销毁该 cwd 的运行时会话) |
五、读会话:sessions 组#
列表与字段#
curl -s -u "pi:$PW" "$BASE/api/sessions"返回 { sessions, runningSessionIds, completionNotificationSuppressedSessionIds }。它的规模可以很大——在会话多的实例上,几百条会话、几十个 cwd 目录是常态。
每个 session 的关键字段:
id/path/cwd—— 会话 id、jsonl 绝对路径、工作目录("哪个文件夹"就是这个)name?—— 改过名才有这个字段created/modified—— 列表按modified降序messageCount—— 累计消息数,含已被压缩掉的历史firstMessage—— 第一条 user 消息,没有就是"(no messages)"projectRoot?/projectKey?/branch?/isWorktree?—— 由服务端调 git 补出来的项目信息(这也是列表有缓存的原因)parentSessionId?/relation?—— fork 或 subagent 关系transient?—— 只存在于内存、jsonl 还没落盘
force=1 会绕过缓存:实测这个参数要花约 10 秒(要重扫全部会话文件,还带 git 调用),平时别加。
单个会话#
curl -s -u "pi:$PW" "$BASE/api/sessions/$ID?tail=20&deferThinking&deferMedia"返回 { sessionId, filePath, info, leafId, tree, context, stats, totalActiveMs, toolNames? }。
tail默认 50、上限 1000deferThinking/deferMedia把 thinking 块和图片换成懒加载(正文分别走那两条entries/...路由)context里entryIds与messages一一对应;model/thinkingLevel是沿parentId往上找最近的变更,也就是当前生效值注意
info.messageCount会大于context.messages.length(前者是全部 entries 的累计,含被压缩掉的历史)
状态:判断"在不在干活"#
curl -s -u "pi:$PW" "$BASE/api/sessions/$ID/state"在跑 → {"running":true,"state":{...}};不在跑但磁盘上有 → {"running":false}(200,不是 404)。
state 里真正有用的:
isStreaming/isPromptRunning/isBashRunning/isCompactingcontextUsage: {percent, contextWindow, tokens}—— "上下文快满了"就看这个,实测常见形态是{"percent":28.47,"contextWindow":262144,"tokens":74639}queuedMessages、pendingMessageCount、extensionStatuses
一个坑:state.messageCount 在当前实现里硬编码为 0,别拿它计数(计数用 stats.totalMessages)。
这条路由不会重置空闲计时器——所以轮询它不会把会话"叫醒",可以放心用。
其他#
GET .../context?tail=&before=&leafId=:向上翻页。hasMore:false表示爬到顶了。GET .../export:导出自包含单文件 HTML,实测最小会话也要 2 秒出 270KB。这个端点做了件很细的事——导出模板里有三个递归函数,深线性会话会爆栈,所以它用"必须恰好命中 1 次"的严格替换把递归改成迭代。PATCH .../{id}body{"name":"..."}:重命名,追加一条session_info,不落 SQL。注意它只认磁盘上的会话,不认还没落盘的 transient 会话。DELETE .../{id}:删 jsonl + 关运行时会话。它会顺手把同目录下指向它的子会话重挂到它的父会话上——别的目录下的子会话不会被重挂,这是真实边界。POST .../auto-name:会真调一次模型生成标题;会话没在跑会先把它拉起来。
六、接管对话:agent 组(DIY 的核心)#
新建一个会话并下发任务#
curl -s -u "pi:$PW" -X POST "$BASE/api/agent/new" \
-H 'Content-Type: application/json' \
-d '{"cwd":"/path/to/your/project","type":"prompt","message":"先读 README 再总结",
"provider":"deepseek","modelId":"deepseek-v4-flash","thinkingLevel":"low"}'cwd必填(只检查存在),type实际必填("ensure_session"只建会话不下发消息)provider+modelId必须成对,给一半会 500toolNames只接受内置白名单bash|read|edit|write|grep|find|ls|powershell;传[]会走 chat-only 分支返回
{success:true, sessionId, data, model, thinkingLevel}
实现上的小细节:它用 __new__<uuid> 当临时键,而没用时间戳——因为同键的并发调用会被合并成同一个会话,毫秒级会撞车。
给已存在的会话发命令#
curl -s -u "pi:$PW" -X POST "$BASE/api/agent/$SID" \
-H 'Content-Type: application/json' -d '{"type":"prompt","message":"用方案 A,继续"}'这是把答案抛回对话的入口。 type 支持的完整命令表:
| type | 关键字段 | 说明 |
|---|---|---|
prompt | message、images?、streamingBehavior? | 下发消息,受理即返回,不等跑完 |
steer / follow_up | message、images? | 流式过程中的引导 / 排队 |
abort | — | 中止 |
get_state | — | 取状态快照 |
get_last_assistant_text | — | 取最后一段助手回复 |
set_model | provider、modelId | 换模型 |
set_tools | toolNames | 换工具(会话在跑时不允许,会重建会话) |
set_thinking_level | level | 换思考档位 |
set_session_name | name | 改名 |
compact / abort_compaction | — | 压缩上下文 |
fork / clone | entryId? | 分支 |
navigate_tree | entryId、summarize? | 切分支 |
bash / abort_bash | command、excludeFromContext? | 直接跑 shell |
clear_queue、get_tools、get_commands、reload | — | 杂项 |
要点:
prompt立刻返回null,只代表"受理了"。真正的输出要走 SSE。会话不在内存里但磁盘上有时,这个接口会冷启动它再发命令——也就是能"复活"旧对话。
会话正忙时
prompt会被拒(Cannot send a prompt while a shell command is running)。图片最多 10 张、每张 ≤10MB base64。
实时流#
timeout 5 curl -s -N -u "pi:$PW" "$BASE/api/agent/$SID/events"全是
data:行,没有event:名,类型在 JSON 的type字段里固定先发
{"type":"connected","sessionId":...},就绪前到达的事件会先缓冲再回放30 秒一次心跳注释帧
turn_start/turn_end被丢弃,agent_end被裁剪成只剩 type,message_update去掉了累积快照字段重要副作用:这条路由会冷启动会话。打开一个旧会话的 SSE 等于把它唤醒。
会话文件找不到时返回的是 404 纯文本
Session not found,不是 JSON
浏览器里 EventSource 不能带自定义头,所以浏览器侧只能靠 cookie。
最便宜的全局轮询#
curl -s -u "pi:$PW" "$BASE/api/agent/running"
# {"runningSessionIds":["<sessionId>"],"completionNotificationSuppressedSessionIds":[]}判据是进程内的:alive && (有 pending prompt || streaming || compacting || bash 在跑)。
代价要记住:它只知道"进程活着的会话"。pi-web 重启后这些 id 全空——因为重启后没有任何会话被唤醒,磁盘上的历史会话一个都不在里面。
七、文件、Git 与工作目录#
files 的路径语义(最容易踩)#
/api/files/[...path] 的 segments 拼起来就是去掉开头斜杠的绝对路径:
curl -s -u "pi:$PW" "$BASE/api/files/path/to/your/project/lib?type=list"
# ^^^^^^^^^^^^^^^^^^^^ 这里对应 /path/to/your/project/lib所以 /api/files/lib 指的是根目录下的 /lib,不是"相对某个 cwd"。
type 取值:list(默认)/ read / download / meta / preview / watch(SSE 监听文件变化)。
文本读取上限 256KB,图片 10MB,超限 413
图片/音频/PDF 走原始字节流,不是 JSON
.svg额外加了 CSP(防同源脚本执行)watch监听的是父目录并按路径过滤,还会比较mtime/ctime/ino/size丢弃纯读取产生的事件,避免自己刷新自己
访问控制是"允许根"白名单:所有历史会话的 cwd 和 projectRoot,加上 ~/pi-cwd-YYYYMMDD,加上运行时被授权的目录。不在里面 → 403 Access denied。路径检查做两次:先词法,再对 realpath 检查一次——软链接躲不过第二次。
上传:POST ?type=upload,字段名固定 files,单文件 25MB、合计 100MB;撞名用 conflict=error|overwrite|skip 控制;有部分失败时返回 207。
工作目录#
POST /api/cwd/validatebody{"cwd":"..."}—— 校验目录,并且把这个目录加进允许根(这就是"授权"这一步本身)。返回projectRoot/projectKey。POST /api/default-cwd—— 建~/pi-cwd-YYYYMMDD并返回。日期取的是 UTC,所以跨时区的深夜会拿到"昨天/明天"的目录名。GET /api/file-index?cwd=&q=—— 模糊搜索。排序在完整列表上做、截断在匹配之后,所以仓库再大也能搜到深处的文件。git 仓库走git ls-files,非 git 退化为 BFS。
Git#
curl -s -u "pi:$PW" "$BASE/api/git/status?cwd=/path/to/your/project"
curl -s -u "pi:$PW" "$BASE/api/git/diff?cwd=...&path=<绝对路径>"status对非 git 目录不是错误:返回isGitRepository:false+ 空数组(200)。只看落在cwd子树内的变更。diff对"没变更的文件"返回{"supported":false}(200),不是错误。未跟踪文件的 patch 是服务端合成的。
worktree#
GET /api/worktrees?cwd= 会解析项目 + 列出 worktree;POST/DELETE 建删。删除时如果工作区脏,会返回 409 + dirty:true,让你确认是否强制删。
八、模型、插件、技能#
模型列表#
curl -s -u "pi:$PW" "$BASE/api/models?cwd=/path/to/your/project"返回 models / modelList / defaultModel / thinkingLevels 等。cwd 必须在允许根里,问 /etc 会 403。5 分钟缓存,加载失败不报 500,而是返回空列表 + modelError。
models.json 读写#
curl -s -u "pi:$PW" "$BASE/api/models-config" # 读
curl -s -u "pi:$PW" -X PUT -H 'content-type: application/json' \
--data @/tmp/models.json "$BASE/api/models-config" # 覆盖写两条要命的:
GET会把apiKey明文返回,不脱敏。这不是理论——它真的会把配置里每个 provider 的 key 裸着回给你,所以别把这个响应贴到任何地方。PUT是整文件替换,不是合并。正确姿势永远是 GET → 改 → PUT 回去,否则没带上来的 provider 直接没了。写盘是原子写 + 权限 0600。
配套三条:catalog 从 models.dev 搜模型给价格建议(1 小时缓存,上游挂了回退旧值);discover 拿 baseUrl + key 问上游有哪些模型;test 发一条真实请求测通不通——test 不改你的真实配置,它会在临时目录写一份只含该模型的 models.json 再 boot 一次。
插件#
GET /api/plugins?cwd= 列包与资源、POST 装卸更新。disable/enable 只改 settings.json,install/update 会真的联网拉 npm/git。
技能#
GET /api/skills?cwd= 列的是"这个 cwd 下实际可用的全部 skill"(复用 SDK 加载器,不是扫目录)。
PATCH /api/skills 就是那条"关 skill"操作的 HTTP 化,body {filePath, disableModelInvocation}——按键存在与否精准改行,不会重写整个 YAML,正文里出现的同名字样不受影响。
install / update 底层都是 npx skills add ... --agent pi,超时 60 秒,不经过 shell。search 先试 HTTP API,失败回落 npx skills find。
实测一个反直觉的现状:check/update 这两个接口实际可用数可能是 0。它们要求 skill 的锁文件里同时有 sourceType=github、skillPath、versionHash 这几项,而很多 skill 是通过 well-known 方式或符号链接装进来的,不满足条件——实测一批 42 个 skill 里只有 1 个带 install 元信息,而它偏偏 canCheckForUpdates:false。用之前先用 GET /api/skills 看一眼有没有带 install 的。
九、鉴权、推送与子代理#
登录#
GET /api/auth/providers 列出可登录的 provider(实测常见形态:7 个左右 OAuth provider,loggedIn 多为 false)。
OAuth 是两段式,不是轮询:
EventSource GET /api/auth/login/<provider>
← SSE {"type":"auth", url, instructions, token}
浏览器打开 url 授权
→ POST /api/auth/login/<provider> {token, code}
← SSE {"type":"success"} 或 {"type":"error"}token 是进程内存注册表里的键,Next.js 重启即失效。而且 POST 返回 ok 只代表"回填成功",不代表登录成功——成功信号只从 SSE 的 success 来。
POST/DELETE /api/auth/api-key/[provider] 存删 API Key,两个方向都是类型安全的(OAuth 凭据不会被误删,反之亦然),类型不匹配给 409。
Web Push#
GET /api/push/config 给 VAPID 公钥(首次调用会现场生成密钥对并落盘到 ~/.pi/agent/web-push.json,权限 600,私钥永不出服务端)。POST /api/push/subscribe 登记订阅,按 endpoint 去重 upsert,所以前端每次加载都能放心重发。
子代理#
GET /api/subagents/profiles?cwd= 列全部 profile(含被遮蔽的),PUT/PATCH/DELETE 管理(DELETE 也要带 body)。tools 字段会被白名单过滤,只留 7 个内置工具名,其余静默丢弃。
一个值得知道的现状:GET/PUT /api/subagents/settings 那个"内置子代理扩展"开关,在 0.8.11 里写死了 return false,测试里还明确断言"即使文件写了 true 也返回 false"。所以内置子代理扩展当前处于关闭状态,/api/subagents/[id] 基本只能查到历史 run(或者直接 404)。
项目信任#
GET /api/project-trust?cwd= 查某个目录是否需要信任。它读的是 ~/.pi/agent/trust.json,结构是"绝对路径 → true/false/null",查找是就近祖先语义——给父目录记 true 会连带信任所有子目录。
POST 信任某项目时有副作用:写 trust.json 之后会销毁该 cwd 上所有运行中的会话,让它们下次以"已信任"重新加载资源。它还要求该 cwd 上没有 busy 的会话,否则 409。
十、DIY 配方#
1. 一眼看全部对话 + 谁在跑#
# 注意 `. as $s` 那一行不能省:jq 里 index() 的参数是在管道左侧的输入上求值,
# 直接写 index(.id) 会去 runningSessionIds 自己身上找 .id,恒为 null
curl -s -u "pi:$PW" "$BASE/api/sessions" \
| jq -r '. as $d | $d.sessions | sort_by(.modified) | reverse | .[:15][]
| . as $s
| "\(if ($d.runningSessionIds | index($s.id)) then "▶ 跑" else " 停" end) \($s.cwd) \($s.messageCount)条 \($s.firstMessage[:30] | gsub("\n";" "))"'输出长这样:
▶ 跑 /path/to/project-a 106条 先读一下 README 再总结这个项目
停 /path/to/project-b 79条 帮我查一下这个报错是怎么来的2. 粗判"哪些对话在等我"#
纯结构化信号,不用 AI:最后一条是 user 消息,就说明这个对话在等你。会话 jsonl 里 messages 有 role,取最后一条判断即可。
遍历 ~/.pi/agent/sessions/**/*.jsonl,读最后一条 message 的 role:
role == "user" → 在等你
assistant / toolResult → 跑完了,或还在干活这条判据不需要模型、不会猜错,但只能给出"可能",不能给出"完成/未完成"——因为 pi 的会话记录里根本没有结构化的完成状态。
3. 把答案抛回某个对话#
SID=<你的会话 id>
curl -s -u "pi:$PW" -X POST "$BASE/api/agent/$SID" \
-H 'Content-Type: application/json' \
-d '{"type":"prompt","message":"按方案 B 来,改完跑测试"}'发完想盯结果就接 SSE:
curl -s -N -u "pi:$PW" "$BASE/api/agent/$SID/events" | grep --line-buffered '"type":"agent_end"'4. 新开一个无人值守的会话#
curl -s -u "pi:$PW" -X POST "$BASE/api/agent/new" \
-H 'Content-Type: application/json' \
-d '{"cwd":"/path/to/your/project","type":"prompt","message":"跑一遍测试并报告失败项"}'5. 把会话导出给别人看#
curl -s -u "pi:$PW" -OJ "$BASE/api/sessions/$SID/export" # 得到自包含 HTML单文件、带会话树和搜索,可以直接发给别人。
十一、坑与风险(写博客时顺手记下的)#
功能性的
GET /api/sessions默认 30 秒缓存;要最新得force=1,但那个要花约 10 秒。state.messageCount恒为 0,别用它。计数用stats.totalMessages。GET /api/agent/[id]/events会冷启动会话——别拿它当"只订阅不打扰"的用法。如果你用
--hostname把它绑到了非回环地址(比如容器网桥),那127.0.0.1:30141就连不上,得用实际绑定的地址。所有缓存(会话列表、模型列表、版本检查、登录 token、允许根)都在内存里,重启即清。别当持久状态用。
PUT /api/models-config是整文件覆盖,必须先 GET 后 PUT。三个接口在"JSON 非法"时行为不一致:
auth/login和auth/api-key给 500,auth/web/login给 400。POST /api/cwd/validate、POST /api/worktrees、POST /api/project-trust都有副作用(授权目录 / 建目录 / 销毁会话),不是纯读。
安全面的(我自己看完最在意的三条)
GET /api/models-config明文返回所有apiKey。任何能过认证的人(或任何拿到密码的脚本)都能一次性读走全部上游 key。GET /api/cwd/browse没有允许根校验(它只过 Host + 密码那一层)。实测?path=/etc能正常列出宿主机任意可读目录的目录名——拿不到文件内容(内容受/api/files白名单管),但足以摸清机器结构。登录接口没有速率限制:
/api/auth/web/login只做恒定时间比较,可以无限次试密码;cookie 也没设Secure;登出只清客户端 cookie、不吊销旧 cookie(服务端无状态),想强吊销只能改密码。
结论很简单:这东西是本地高权限工具,不要裸露到公网。要走外网就套 HTTPS 反代 + 额外限速,或者干脆只对着 VPN 开。
结语#
47 个路由里,真正撑起"把我的对话调度起来"这件事的只有四个:GET /api/sessions(有哪些对话)、GET /api/agent/running(谁在跑)、GET /api/sessions/[id]/state(它什么状态)、POST /api/agent/[id](把话递回去)。
剩下的都是配套。摸清这四个,再往上搭自己的面板就是拼装了——反而"这个对话到底完成没有"这个判断,pi-web 这条链路上给不了你,因为 jsonl 里从来没有这个字段。那得靠别的手段。
讨论 0
还没有人说话。
登录登录后可以参与讨论