pinomad 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,222 +1,100 @@
1
1
  # PiNomad
2
2
 
3
- 以 coding 为核心的 agent 产品,基于 [Pi Durable](https://www.npmjs.com/package/@earendil-works/pi-durable)。
3
+ English · [中文](README.zh-CN.md)
4
4
 
5
- - 自托管宿主:在你自己的机器(Mac mini、Linux VPS)上运行,直接使用本机的 shell 和代码仓库;执行可在崩溃或重启后恢复。
6
- - 多端客户端:桌面、Web、移动端连接同一个宿主,界面由 PiNomad 统一定义。
5
+ A self-hosted coding agent built on [Pi Durable](https://www.npmjs.com/package/@earendil-works/pi-durable). The agent runs on your own machine; the Mac app, the web client, and the iPhone app all attach to the same runs.
7
6
 
8
- 架构决策见 [`docs/adr/`](docs/adr/README.md),路线图见 [`docs/roadmap.md`](docs/roadmap.md),术语见 [`CONTEXT.md`](CONTEXT.md)。
7
+ ![One run streaming live to the Mac app, an iPhone, and the web client](docs/assets/demo.jpg)
9
8
 
10
- ## 运行
9
+ <sub>One run, three clients: Mac app (left), iPhone via iPhone Mirroring (center), web (right). The host is a Linux box on the same tailnet.</sub>
11
10
 
12
- 安装只需要 Node 25+(宿主依赖 `node:sqlite`),不需要 Bun:
11
+ - **Your machine does the work.** The host runs on a Mac mini, a Linux box, or a VPS, and the agent uses that machine's shell and repositories.
12
+ - **Runs survive crashes and restarts.** Pi Durable checkpoints every model call and tool call, so a restarted host picks up where it stopped.
13
+ - **Clients are just windows.** Start a task at your desk, then check on it or steer it from your phone. Devices pair with a one-time QR code and talk to the host over an end-to-end encrypted channel: directly on your LAN or tailnet, or through a relay that only sees ciphertext.
13
14
 
14
- ```sh
15
- npm i -g pinomad
16
-
17
- # 起宿主:真实模型需要先在 pi 里配好认证(读 ~/.pi/agent);
18
- # 没有认证可以用脚本化的假模型冒烟:
19
- pinomad host --faux "hi"
20
-
21
- # 打印带 token 的浏览器链接,打开即用(Web 客户端由宿主自己提供):
22
- pinomad link
23
- ```
24
-
25
- ### 开发 / 从源码运行
26
-
27
- 检出仓库,需要 Bun(装依赖、跑脚本)+ Node 25+:
28
-
29
- ```sh
30
- bun install
31
-
32
- bun run host -- --faux "hi"
33
-
34
- # 另一个终端起 Web 客户端开发服务器(http://127.0.0.1:5199):
35
- bun run web
36
- bun run link
37
- ```
38
-
39
- 默认数据目录是 `~/.pinomad`(session.sqlite、token、目录锁),可用 `--data-dir` 或 `PINOMAD_DATA_DIR` 覆盖;同一数据目录同时只能有一个宿主进程。
40
-
41
- 对话按项目组织(ADR-0007):在客户端侧栏点 Add project 登记一个宿主上的目录,也可以启动时用可重复的 `--project DIR` 登记;不选项目的对话归入 Chat。宿主启动时不再自动建对话,第一条消息发出时才新建。
42
-
43
- Git 项目的对话默认在独立 worktree 里工作(ADR-0010):宿主在 `<数据目录>/worktrees/<对话 id>` 检出项目仓库,分支是 `pinomad/<对话 id>-<随机>`,起点是创建时的 HEAD;项目的原目录不会被改动,改动留在分支上,由你自己 merge 或发 PR 合回。新建对话时勾选 "Work directly in project directory" 可以直接在项目目录里工作;非 Git 目录和还没有 commit 的仓库也直接使用项目目录。fork 会得到自己的 worktree,内容复制自父对话在 fork 那一刻的文件状态(已提交、未提交、未跟踪的都在,被 gitignore 的文件不复制);子代理和所属对话共用一个 worktree。归档对话时干净的 worktree 目录会被删除(分支保留),有未提交改动的保留并提示。注意 worktree 是全新检出,`node_modules`、`.env` 这类被忽略文件不在里面,agent 需要时按 AGENTS.md 自行安装。如果移动或删除了数据目录,在仓库里跑一次 `git worktree prune` 清掉失效的登记。
15
+ > **Status:** early and moving fast. Pi Durable is itself experimental, and the host and its clients must speak the same protocol major version.
44
16
 
45
- Chat 对话在 `<数据目录>/chats/<对话 id>` 里工作。fork 和子代理的对话显示在所属对话下面。归档只是隐藏,移除项目也不会删除目录和对话。
17
+ ## Quick start
46
18
 
47
- agent 用 read / write / edit / bash 工具,执行前不需要审批;拿不准方向时用 `ask_user_question` 向用户提 1–4 个带选项的问题,问题卡片显示在输入框上方,用户也可以不回卡片、直接在对话里回复(ADR-0011)。agent 还可以用 `subagent` 把一个自包含的任务交给子代理:子代理在自己的对话里从零开始(看不到父对话),和父对话共用同一个检出,同一轮里的多个子代理并发执行;它不能向用户提问,需要澄清时写进回答交回父代理;默认沿用父对话的模型和思考等级,也可以在调用时用 `model`(`provider/modelId`)和 `thinkingLevel` 指定。中止父对话会连带中止子代理。edit / write 的结果带 unified diff,渲染成 diff 而不是文本;对话标题栏的 Changes 按钮随时查看当前检出的全部改动(worktree 相对基线 commit,项目目录则是未提交的改动),busy 转空闲时自动刷新。agent 还有 `codemode`(ADR-0013):写一段 JavaScript,在 QuickJS 沙箱里调用其他工具,只有脚本的输出进入上下文,适合并发读多个文件、先过滤大段 bash 输出再看、批量改文件,以及调用 MCP 工具。脚本里能调 read / bash / edit / write 和 MCP 工具,不能调 `ask_user_question`、`subagent` 和 `codemode` 自己;一次 codemode 调用在 Web 上是一张卡片,列出脚本、每次嵌套调用的状态和耗时(edit / write 的 diff 可以展开),以及脚本输出。嵌套调用不单独进 transcript,宿主崩溃时整段脚本按中断处理,已经执行的调用不会撤销。系统提示词会带上以下内容,每次请求都重新读取(ADR-0006):
48
-
49
- - AGENTS.md:`~/.agents/AGENTS.md`,以及从根目录到 cwd 沿途的 AGENTS.md / CLAUDE.md;
50
- - Skills:`<cwd>/.agents/skills` 和 `~/.agents/skills`,同名时项目的生效。
51
-
52
- MCP server 在 `~/.agents/mcp.json` 里配置(ADR-0012),格式是各家客户端通用的 `mcpServers`:
53
-
54
- ```json
55
- {
56
- "mcpServers": {
57
- "docs": { "command": "npx", "args": ["-y", "@example/docs-mcp"] },
58
- "github": { "url": "https://api.githubcopilot.com/mcp/", "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } },
59
- "browser": { "command": "npx", "args": ["-y", "@playwright/mcp"], "exposure": "direct", "description": "Drive a browser" },
60
- "local": { "command": "my-mcp-server", "env": { "KEY": "${MY_KEY}" }, "enabled": false }
61
- }
62
- }
63
- ```
64
-
65
- stdio server 写 `command`(可选 `args`、`env`、`cwd`——相对的 cwd 按主目录解析);HTTP server 写 `url`(可选 `headers`)。`env` 和 `headers` 的值可以用 `${NAME}` 引用宿主进程的环境变量。`timeout`(秒,默认 60)是单次工具调用的超时;`enabled: false` 保留配置但不连接。每个 server 在宿主里只连一份、所有对话共用;工具名是 `mcp__<server>__<tool>`。`exposure` 决定模型怎么够到它们(ADR-0013):默认 `codemode`,工具不声明给模型,只能在 codemode 脚本里调用,模型用 `searchTools` / `describeNamespace` 找到它们,server 连上前后工具声明不变;写 `"direct"` 则同时直接声明给模型,结果里的图片显示在工具卡片里。`description`(可选)是给模型看的一句话说明,列在系统提示词里;不写时只列 server 名和工具数,server 自带的 instructions 由 `describeNamespace` 返回。右侧状态面板的 MCP 区列出每个 server 的状态、工具数和错误,以及配置错误(比如引用了一个不存在的环境变量)。配置只在启动时读一次,改动后重启宿主生效(常驻模式用 `bun run service -- restart`)。暂不支持:项目级 `mcp.json`、OAuth 登录(server 回 401 会在状态里提示)、resources / prompts、`deferred` / `tool_search`。
66
-
67
- ## 远程访问(ADR-0008)
68
-
69
- 一条命令起全部(构建 Web、起宿主、起 vite),并在终端打印电脑浏览器链接和手机配对二维码:
19
+ The host needs Node 25+ on macOS or Linux.
70
20
 
71
21
  ```sh
72
- bun run start
73
- # 透传宿主参数:bun run start -- --faux "hi" --data-dir /tmp/demo
22
+ npm i -g pinomad
23
+ pinomad host # uses the models and credentials you set up in pi (~/.pi/agent)
24
+ pinomad link # in another terminal: prints a browser link to the web client
74
25
  ```
75
26
 
76
- 手机扫二维码即完成配对(Noise IK 端到端加密 + 设备登记);二维码是一次性的,5 分钟过期,过期后用 `bun run pair` 重发一个。已登记的设备和吊销在 Web 侧栏的 Devices 里管理;吊销设备也可以在那直接点 Revoke。
27
+ Haven't set up pi yet? `pinomad host --faux "hi"` runs a scripted fake model so you can click around.
77
28
 
78
- <details><summary>手动/高级:分开跑各个进程</summary>
29
+ ## Connect your other devices
79
30
 
80
- 默认只监听 `127.0.0.1`。`--remote-port N` 另起一个监听所有网卡的安全通道端口,同端口用 HTTP 提供构建好的 Web 客户端(`apps/web/dist`,缺失时页面是 503):
31
+ Open a port for paired devices, then pair each device with its own QR code:
81
32
 
82
33
  ```sh
83
- bun run build
84
- bun run host -- --remote-port 7422
85
- bun run web # 电脑上的开发服务器 http://127.0.0.1:5199
86
- bun run link # 打印带 token 的浏览器链接
87
- bun run pair # 打印配对二维码(一次性,5 分钟过期)
88
- # 走隧道/自建入口,或 LAN 地址探测选错网卡时用 --public-url 覆盖:
89
- bun run host -- --remote-port 7422 --public-url https://pinomad.example.com
34
+ pinomad host --remote-port 7422
35
+ pinomad pair # one-time QR code and link, expires after 5 minutes
90
36
  ```
91
37
 
92
- 远程端口不接受 token:只有完成配对、公钥已登记在设备表里的客户端能连。不开 `--remote-port` / `--relay` 时 `pair` 打印的是回环链接(回环端口 `/secure` 路径上的安全通道),只能给本机上的客户端用,比如桌面端 App。桌面端 App 可以配对多台宿主,在侧栏底部切换(一次只连一台)。App 会自动检查更新(GitHub Release 上的 `latest-mac.yml`,自动下载、不自动安装),下载完成后侧栏底部出现 "Restart to update",也可以在菜单里手动 Check for Updates…。
38
+ Scan the code with the iPhone app, or paste the link into the Mac app. The remote port accepts paired devices only; revoke them under Devices in the sidebar.
93
39
 
94
- </details>
40
+ The pairing link uses the LAN address the host detects. On a tailnet or behind a tunnel, add `--public-url http://<tailnet-ip>:7422` so the link points there. To reach a host at home from anywhere, run the relay on a VPS: see [Remote access](docs/remote-access.md).
95
41
 
96
- ### 经自部署中继访问
42
+ ## Keep it running
97
43
 
98
- 宿主在家里局域网、手机在外面时,把中继跑在一台有公网地址的 VPS 上:宿主和手机都只往外连中继,中继按宿主身份把两端的连接接起来,只转发密文、不存东西;Web 客户端也由中继用 HTTPS 提供,手机扫二维码直接打开。
99
-
100
- 宿主这边:先打印中继身份(Ed25519 公钥,存在 `<dataDir>/relay-key`):
44
+ Install the host as a user service (LaunchAgent on macOS, systemd user unit on Linux) so it starts at boot and restarts on crash:
101
45
 
102
46
  ```sh
103
- pinomad relay-id [--data-dir DIR] # 输出 hostId,把它加进中继的 --allow-host
104
- pinomad host --relay https://relay.example.com
105
- # 常驻:pinomad service install --relay https://relay.example.com
106
- pinomad pair # 打出的二维码指向中继
107
- ```
108
-
109
- VPS 这边:同一个包(只要 Node 25+)——中继和 Web 客户端来自同一个 `pinomad` 安装,版本天然一致:
110
-
111
- ```sh
112
- npm i -g pinomad
113
- pinomad relay --public-origin https://relay.example.com --allow-host <hostId>
114
- # 默认只监听 127.0.0.1:7430,--port / --listen 可改;多台宿主就写多个 --allow-host
115
- ```
116
-
117
- Caddy 终止 TLS(自动签证书,WebSocket 无需额外配置):
118
-
119
- ```
120
- relay.example.com {
121
- reverse_proxy 127.0.0.1:7430
122
- }
123
- ```
124
-
125
- systemd 单元示例(VPS 上的系统级单元;node 和包路径都用绝对路径,`command -v node` 和 `npm root -g` 查):
126
-
127
- ```ini
128
- [Unit]
129
- Description=PiNomad relay
130
- After=network-online.target
131
-
132
- [Service]
133
- User=pinomad
134
- ExecStart=/usr/bin/node /usr/lib/node_modules/pinomad/dist/pinomad.js relay --public-origin https://relay.example.com --allow-host <hostId>
135
- Restart=always
136
-
137
- [Install]
138
- WantedBy=multi-user.target
47
+ pinomad service install --remote-port 7422
48
+ pinomad service status
49
+ pinomad upgrade # install pinomad@latest, wait for running tasks, restart
139
50
  ```
140
51
 
141
- #### 容器平台(Zeabur 等)
52
+ Service flags, logs, and platform caveats: [Running as a service](docs/service.md).
142
53
 
143
- 镜像在 `deploy/relay/Dockerfile`,装的是锁版本的 npm 发布包(`ARG PINOMAD_VERSION`)。平台终结 TLS 并转发到 `$PORT`(默认 8080),不需要 Caddy。中继把转接状态放在内存里,**只能跑一个实例**,不要开扩容。
54
+ ## Clients
144
55
 
145
- | 环境变量 | 含义 |
56
+ | Client | How to get it |
146
57
  |---|---|
147
- | `PINOMAD_PUBLIC_ORIGIN` | 客户端打开的 https origin,比如 `https://nomad.example.com` |
148
- | `PINOMAD_ALLOW_HOST` | 允许登记的 hostId,多个用空格分隔(每台宿主上跑 `pinomad relay-id`) |
149
- | `ZBPACK_DOCKERFILE_PATH=Dockerfile` | **Zeabur 必加**:目录里只有一个 Dockerfile 时,Zeabur 会把它识别成静态站点、用 Caddy 托管文件,中继根本没启动;这个变量强制按 Dockerfile 构建(部署日志里应是 `PLANTYPE=docker`) |
150
-
151
- Zeabur 上的步骤(在 Dedicated Server 上部署,用 CLI 上传目录,不接 GitHub):
152
-
153
- ```sh
154
- cd deploy/relay
155
- zeabur deploy --project-id <项目 id> --create --name pinomad-relay # 首次:在项目里建出服务
156
- # 之后重新部署同一个服务:
157
- zeabur deploy --service-id <服务 id>
158
- ```
159
-
160
- 然后在服务上配好上面的环境变量(`PORT` 由平台注入,不用自己设),在 Domains 里绑定自定义域名,到 DNS 加一条记录:Dedicated Server 用 **A 记录指向服务器 IP**;Cloudflare 上建议**关掉代理(灰云)**,橙云会让国内访问多绕一圈,证书也由 Zeabur 自己签。域名状态变成 `PROVISIONED` 后,验证:
161
-
162
- ```sh
163
- curl -sI https://nomad.example.com/ # 200,中继托管的 Web 客户端
164
- pinomad service install --relay https://nomad.example.com … # 宿主接上中继
165
- ```
166
-
167
- 宿主登记成功会在日志里写一行 `{"event":"relay","status":"registered",…}`,掉线重连时写 `"status":"connecting"`(`pinomad service logs` 查看);也可以从外面连设备入口 `wss://<origin>/c/<hostId>` 确认:宿主在线时连接保持打开(等握手),没登记会立刻以 `4604`(hostOffline)关闭。
168
-
169
- 也可以从 GitHub 仓库部署:Root Directory 设 `deploy/relay`、Watch Paths 限 `deploy/relay/**`,其余同上。
170
-
171
- #### 升级
58
+ | Web | Served by the host itself (`pinomad link`) and by the relay; any modern browser |
59
+ | macOS (Apple silicon) | Signed DMG from [GitHub Releases](https://github.com/bubbleptr/pinomad/releases); updates itself |
60
+ | iOS | Internal TestFlight only for now |
61
+ | Linux, Windows | Use the web client |
172
62
 
173
- 中继托管的 Web 客户端和宿主版本不一致时,客户端会显示需要更新;两边一起升级——宿主 `pinomad upgrade`,VPS 上 `npm i -g pinomad@latest` 后重启中继;容器部署则是改 Dockerfile 里的 `PINOMAD_VERSION`,再在 `deploy/relay` 下重新 `zeabur deploy --service-id <服务 id>`(从 GitHub 部署的话合进 main 即触发)。宿主不在中继的 `--allow-host` 里时,宿主日志和 Web 警告会带着要补的 `--allow-host <hostId>`。
63
+ ## What the agent can do
174
64
 
175
- npm 装的实例和本机任何开发检出互不相干(各自的数据目录、进程、包路径都独立),所以在同一台机器上开发 PiNomad 不会碰到正在跑的服务。
65
+ The agent has pi's four tools (read, bash, edit, write) plus:
176
66
 
177
- ## 常驻运行(ADR-0009)
178
-
179
- 把宿主装成当前用户的系统服务(macOS 是 LaunchAgent,Linux 是 systemd 用户单元),开机自启、崩溃自动拉起(从源码检出跑时用 `bun run service -- …`,下同):
180
-
181
- ```sh
182
- pinomad service install [--data-dir DIR --port N --remote-port N --relay URL --project DIR …]
183
- pinomad service status [--data-dir D] [--url U]
184
- pinomad service restart [--force] [--data-dir D] [--url U]
185
- pinomad service logs [--follow] [-n N] [--data-dir D]
186
- pinomad service uninstall
187
- pinomad upgrade [--force]
188
- ```
189
-
190
- - `install` 把宿主参数原样写进服务定义;`--data-dir`(或 `PINOMAD_DATA_DIR`,默认 `~/.pinomad`)展开成显式的绝对路径。改参数就是带新参数再跑一次 `install`。装好后用 `pinomad link` 拿本机浏览器链接、`pinomad pair` 发手机配对码。
191
- - **PATH 在安装时固定**:服务里 agent 的 bash 继承的是安装那一刻终端的 PATH(已经剥掉 `node_modules/.bin` 和 Bun 注入的临时目录)。后来装了新工具、PATH 变了,重新跑一次 `install` 刷新。
192
- - `restart` 先连上宿主读任务图,有未结束的任务就等它们跑完再重启(Ctrl-C 取消);`--force` 立即重启,交给 Durable 恢复。打包安装时 `upgrade` 把 `pinomad@latest` 装进运行包所在的 npm 全局前缀(非全局安装会拒绝执行)+ 同样的 restart;源码检出里(`bun run upgrade`)依次 `git pull --ff-only`、`bun install`、`bun run build`,工作区有未提交改动时拒绝执行。前面任何一步失败都直接停下,不重启,服务继续跑旧代码。
193
- - `uninstall` 只卸载服务,不动数据目录。
194
- - macOS:宿主以登录会话运行,机器重启后要能自动起来需在系统设置里打开自动登录,并为这台机器关掉睡眠。日志写到 `<数据目录>/logs/host.log`。
195
- - Linux:要在未登录时也运行需自己执行 `loginctl enable-linger`(install 检测到没开会提示);日志走 `journalctl --user`。开着 ufw 时,局域网设备访问 `--remote-port` 要先放行,比如 `sudo ufw allow from 192.168.0.0/24 to any port 7422 proto tcp`(只走中继就不用开)。
196
- - **服务绑定安装时的 node 路径**:单元里写的是 node 和包的绝对路径。用 mise / nvm 管理 Node 时,路径里带着具体版本号(`…/node/26.8.2/…`);换了 Node 版本、旧目录被删后服务就起不来,要用新版本重新 `npm i -g pinomad`,再跑一次 `pinomad service install …`。`pinomad upgrade` 只升 pinomad,装回原来的前缀,不受影响。
197
- - 服务模式下**回环端口也用 HTTP 提供构建好的 Web 客户端**。npm 安装时 `pinomad link` 总是指向宿主自己提供的 Web;源码检出里按顺序探测:正在跑的 vite 开发服务器(5199)优先,否则宿主提供 Web 的端口,都没有则仍是 5199。开发时 `bun run start` + vite 照旧。
198
- - 服务在跑时 `bun run start` 会因为目录锁拿不到而失败(同一数据目录只有一个宿主);开发时用另一个 `--data-dir`,或先 `uninstall`。
67
+ | Feature | What it does |
68
+ |---|---|
69
+ | Projects and worktrees | Register a directory as a project. In a Git repo each conversation works in its own worktree and branch, so your checkout stays untouched |
70
+ | Forks | Branch off from a message and keep both lines going |
71
+ | Subagents | Hand self-contained tasks to subagents that run in parallel |
72
+ | Named agents | Reusable roles in `<data dir>/agents/<name>.md` — a `subagent` call or an `@name` mention delegates to one, optionally pinning its model and thinking level |
73
+ | Questions, not approvals | Tools run without approval prompts; when the direction is unclear, the agent asks 1–4 multiple-choice questions |
74
+ | codemode | The model writes JavaScript that calls other tools in a QuickJS sandbox; only the script's output enters the context |
75
+ | MCP | Servers configured in `~/.agents/mcp.json`, in the usual `mcpServers` format |
76
+ | Images | Paste, drop, or pick up to 4 images per message in the Web/desktop composer — resized to JPEG before sending |
77
+ | AGENTS.md and skills | Reads `AGENTS.md` / `CLAUDE.md` and skills from `~/.agents` and the project, on every request |
199
78
 
200
- Windows 不在支持范围;其他平台跑 `service` 会直接报错退出。
79
+ Details and the MCP config reference: [Usage](docs/usage.md).
201
80
 
202
- ## 验证
81
+ ## Documentation
203
82
 
204
- ```sh
205
- bun run typecheck
206
- bun run test # vitest + Playwright,首次跑前先 bunx playwright install chromium
207
- bun run build
208
- ```
83
+ These docs are written in Chinese.
209
84
 
210
- ## 发布(ADR-0016)
85
+ | Doc | Covers |
86
+ |---|---|
87
+ | [Usage](docs/usage.md) | Projects, worktrees, tools, MCP configuration |
88
+ | [Remote access](docs/remote-access.md) | Pairing, tunnels, self-hosted relay (VPS or container platforms) |
89
+ | [Running as a service](docs/service.md) | `service` and `upgrade` commands, platform notes |
90
+ | [Architecture decisions](docs/adr/README.md) | Why things are the way they are |
91
+ | [Roadmap](docs/roadmap.md) | What's done and what's next |
92
+ | [Glossary](GLOSSARY.md) | Domain terms |
211
93
 
212
- 推 `v*` tag 触发 `.github/workflows/release.yml`,三个 job:`verify` 跑 typecheck / test / build、打包、`npm pack`、装包冒烟;`macos`(macos-15)签名 + 公证出 arm64 的 dmg/zip,挂镜像验证签名和公证票据,再跑一遍 `desktop:e2e`;`publish` 先把两者做成 **draft** GitHub Release(`PiNomad <version>`,自动生成 release notes,预发布 tag 打 `--prerelease`),上传 dmg / zip / blockmap / `latest-mac.yml` / `SHA256SUMS.txt`,然后 `npm publish`,全成了才解除 draft。`latest-mac.yml` 就是桌面端自动更新的更新源。带 `-` 的预发布 tag(`v0.2.0-beta.1`)发到 `next` 且不是 latest release,正式版发到 `latest`。发布用 npm trusted publishing(OIDC),仓库里没有 npm token。
94
+ ## Contributing
213
95
 
214
- macOS 签名和公证需要这些仓库 secrets:`MAC_CSC_LINK`(开发者证书的 base64 .p12)、`MAC_CSC_KEY_PASSWORD`(p12 密码)、`APPLE_API_KEY`(App Store Connect 的 .p8 内容)、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`、`APPLE_TEAM_ID`。`workflow_dispatch` 手动触发也跑完整签名公证(版本号 `0.0.0-dryrun`),只是不会走 `publish` job,方便干跑验证发布链。
96
+ Running from source, verification, and the release process are in [CONTRIBUTING.md](CONTRIBUTING.md) (Chinese).
215
97
 
216
- ```sh
217
- git tag v0.1.1
218
- git push origin v0.1.1
219
- ```
98
+ ## License
220
99
 
221
- - 手动发布(流水线不可用时):`bun run build && bun run package -- --version <v>`,再在 `apps/cli/out` 里 `npm publish`。终端里的 npm 要先 `npm login`(`npm whoami` 能报出用户名);没登录时 npm 回的是 `404 Not Found` 而不是 401,容易误判成包名问题。
222
- - 发布后几分钟内 registry 可能还没处理完(新包还会先出现一个 `0.0.0-stage` 占位版本);这期间查过的机器缓存了旧元数据,安装报 `ETARGET` / "No matching version" 时加 `--prefer-online`。
100
+ [Apache-2.0](LICENSE). PiNomad includes code adapted from [Pi](https://github.com/earendil-works/pi) (MIT) and other projects; their notices are in [`docs/licenses/`](docs/licenses/).
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  packagedVersion
3
- } from "./chunk-rkfdsacq.js";
3
+ } from "./chunk-3tf23vjq.js";
4
4
  import {
5
5
  hostAddress
6
6
  } from "./chunk-cjfmfyjb.js";
@@ -1,6 +1,6 @@
1
1
  // apps/host/src/distribution.ts
2
2
  import { fileURLToPath } from "node:url";
3
- var packagedVersion = "0.2.0";
3
+ var packagedVersion = "0.3.0";
4
4
  var webRoot = fileURLToPath(new URL(packagedVersion === undefined ? "../../web/dist" : "../web/", import.meta.url));
5
5
  var hostEntry = packagedVersion === undefined ? { script: fileURLToPath(new URL("./main.ts", import.meta.url)), args: [] } : { script: fileURLToPath(new URL("./pinomad.js", import.meta.url)), args: ["host"] };
6
6
  var commandHint = (name) => packagedVersion === undefined ? `bun run ${name}` : `pinomad ${name}`;
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  commandHint,
7
7
  webRoot
8
- } from "./chunk-rkfdsacq.js";
8
+ } from "./chunk-3tf23vjq.js";
9
9
  import {
10
10
  RELAY_CLOSE,
11
11
  decodeHostToRelay,
@@ -1,10 +1,14 @@
1
1
  import {
2
+ HOST_STREAM_NAMES,
2
3
  INVALID_FRAME_CLOSE_CODE,
3
4
  PROTOCOL,
4
5
  UNAUTHORIZED_CLOSE_CODE,
5
6
  conversationStream,
6
- docStream
7
- } from "./chunk-h35fermn.js";
7
+ docStream,
8
+ hostStreams,
9
+ hostSupportsCall,
10
+ hostSupportsFeature
11
+ } from "./chunk-c155p0t0.js";
8
12
 
9
13
  // packages/protocol/src/remote-durable.ts
10
14
  import { applyImmutable } from "@earendil-works/chord/delta";
@@ -147,7 +151,7 @@ class RemoteClient {
147
151
  #options;
148
152
  #listeners = new Set;
149
153
  #values = new Map;
150
- #wanted = new Set(["conversations", "index", "devices", "mcp"]);
154
+ #wanted = new Set;
151
155
  #fresh = new Set;
152
156
  #docs = [];
153
157
  #toolPresentations = {};
@@ -160,6 +164,10 @@ class RemoteClient {
160
164
  #side;
161
165
  #nextCall = 1;
162
166
  #nextNotice = 1;
167
+ #hostMinor = 0;
168
+ #helloModels = [];
169
+ #helloDefaults = {};
170
+ #hostVersion;
163
171
  #attempt = 0;
164
172
  #closed = false;
165
173
  #notifying = false;
@@ -249,10 +257,25 @@ class RemoteClient {
249
257
  this.#connection?.close();
250
258
  return;
251
259
  }
260
+ const hostMinor = announced !== null && typeof announced === "object" && typeof announced.minor === "number" ? announced.minor : 0;
261
+ this.#hostMinor = hostMinor;
252
262
  this.#attempt = 0;
253
263
  this.#docs = frame.docs;
254
264
  this.#toolPresentations = frame.toolPresentations;
265
+ this.#helloModels = frame.models;
266
+ this.#helloDefaults = frame.defaults ?? {};
267
+ this.#hostVersion = frame.hostVersion;
255
268
  this.#fresh.clear();
269
+ const covered = new Set(hostStreams(hostMinor));
270
+ for (const name of HOST_STREAM_NAMES) {
271
+ if (covered.has(name)) {
272
+ this.#wanted.add(name);
273
+ } else {
274
+ this.#wanted.delete(name);
275
+ this.#values.delete(name);
276
+ this.#fresh.delete(name);
277
+ }
278
+ }
256
279
  if (this.#current !== undefined)
257
280
  for (const stream of this.#conversationStreams(this.#current))
258
281
  this.#wanted.add(stream);
@@ -264,13 +287,13 @@ class RemoteClient {
264
287
  if (this.#state !== undefined) {
265
288
  this.#update({
266
289
  session: frame.session,
267
- models: frame.models,
268
290
  toolPresentations: frame.toolPresentations,
269
- defaults: frame.defaults ?? {},
270
- connection: "connected"
291
+ connection: "connected",
292
+ host: this.#hostInfo()
271
293
  });
294
+ this.#refresh();
272
295
  } else
273
- this.#awaitFirstView(frame);
296
+ this.#awaitFirstView(frame, hostMinor);
274
297
  return;
275
298
  }
276
299
  case "snapshot":
@@ -311,19 +334,23 @@ class RemoteClient {
311
334
  return;
312
335
  }
313
336
  }
314
- #awaitFirstView(hello) {
315
- Promise.all([this.#snapshot("conversations"), this.#snapshot("index"), this.#snapshot("devices"), this.#snapshot("mcp")]).then(() => {
337
+ #awaitFirstView(hello, hostMinor) {
338
+ Promise.all(hostStreams(hostMinor).map((stream) => this.#snapshot(stream))).then(() => {
316
339
  this.#state = {
317
340
  session: hello.session,
318
341
  organized: { chats: [], projects: [] },
319
- models: hello.models,
320
- defaults: hello.defaults ?? {},
342
+ models: this.#host("models") ?? hello.models,
343
+ defaults: this.#effectiveDefaults(hello.defaults ?? {}),
321
344
  notices: [],
322
345
  connection: "connected",
323
346
  docs: [],
324
347
  toolPresentations: hello.toolPresentations,
325
348
  devices: [],
326
- mcp: null
349
+ mcp: null,
350
+ auth: null,
351
+ settings: null,
352
+ agents: null,
353
+ host: this.#hostInfo()
327
354
  };
328
355
  this.#refresh();
329
356
  this.#resolveReady({ view: this.#viewSource(), controller: this.#controller(), close: () => this.#close() });
@@ -360,8 +387,26 @@ class RemoteClient {
360
387
  this.#send({ type: "unsubscribe", stream });
361
388
  }
362
389
  }
390
+ #host(name) {
391
+ return this.#values.get(name);
392
+ }
393
+ #hostInfo() {
394
+ return {
395
+ protocol: { major: PROTOCOL.major, minor: this.#hostMinor },
396
+ ...this.#hostVersion === undefined ? {} : { version: this.#hostVersion }
397
+ };
398
+ }
399
+ #effectiveDefaults(fallback) {
400
+ const settings = this.#host("settings");
401
+ if (settings === undefined)
402
+ return fallback;
403
+ return {
404
+ ...settings.defaultModel === null ? {} : { model: settings.defaultModel },
405
+ ...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
406
+ };
407
+ }
363
408
  #descendsFrom(id, ancestor) {
364
- const summaries = this.#values.get("conversations") ?? [];
409
+ const summaries = this.#host("conversations") ?? [];
365
410
  const parentOf = new Map(summaries.map((summary) => [summary.id, summary.parent]));
366
411
  let cursor = id;
367
412
  for (let depth = 0;cursor !== undefined && depth < 8; depth++) {
@@ -383,12 +428,13 @@ class RemoteClient {
383
428
  const current = this.#current;
384
429
  const side = this.#side;
385
430
  const conversation = current === undefined ? undefined : this.#values.get(conversationStream(current));
386
- const summaries = this.#values.get("conversations") ?? [];
387
- const index = this.#values.get("index") ?? { projects: [], conversations: [] };
388
- const devices = this.#values.get("devices")?.devices ?? [];
431
+ const summaries = this.#host("conversations") ?? [];
432
+ const index = this.#host("index") ?? { projects: [], conversations: [] };
433
+ const devices = this.#host("devices")?.devices ?? [];
389
434
  const tasks = this.#wanted.has("tasks") ? this.#values.get("tasks") : undefined;
390
435
  this.#update({
391
436
  conversation,
437
+ models: this.#host("models") ?? this.#helloModels,
392
438
  organized: organize(index, summaries),
393
439
  home: current === undefined ? undefined : homeOf(index, summaries, current),
394
440
  checkout: current === undefined ? undefined : checkoutOf(index, summaries, current),
@@ -400,7 +446,11 @@ class RemoteClient {
400
446
  docs: this.#docsOf(side)
401
447
  },
402
448
  devices,
403
- mcp: this.#values.get("mcp") ?? null
449
+ mcp: this.#host("mcp") ?? null,
450
+ auth: this.#host("auth") ?? null,
451
+ settings: this.#host("settings") ?? null,
452
+ agents: this.#host("agents") ?? null,
453
+ defaults: this.#effectiveDefaults(this.#helloDefaults)
404
454
  });
405
455
  }
406
456
  #update(patch) {
@@ -435,6 +485,11 @@ class RemoteClient {
435
485
  this.#commands = this.#commands.then(operation).then(() => {}, (error) => this.#notice("error", error instanceof Error ? error.message : String(error)));
436
486
  return this.#commands;
437
487
  }
488
+ #request(operation) {
489
+ const request = this.#commands.then(operation);
490
+ this.#commands = request.then(() => {}, () => {});
491
+ return request;
492
+ }
438
493
  #viewSource() {
439
494
  return {
440
495
  current: () => this.#state,
@@ -452,16 +507,33 @@ class RemoteClient {
452
507
  return this.#current;
453
508
  };
454
509
  return {
455
- addProject: (path) => this.#command(() => this.#call("addProject", { path })),
510
+ addProject: (path) => this.#request(() => this.#call("addProject", { path })),
511
+ listFolders: (path) => this.#request(() => {
512
+ if (!hostSupportsCall(this.#hostMinor, "listFolders")) {
513
+ throw new Error("The host is too old for this (needs protocol 5.3); run `pinomad upgrade` on the host");
514
+ }
515
+ return this.#call("listFolders", path === undefined ? {} : { path });
516
+ }),
456
517
  removeProject: (path) => this.#command(() => this.#call("removeProject", { path })),
518
+ renameConversation: (id, title) => this.#request(async () => {
519
+ if (!hostSupportsCall(this.#hostMinor, "renameConversation")) {
520
+ throw new Error("The host is too old for this (needs protocol 5.4); run `pinomad upgrade` on the host");
521
+ }
522
+ await this.#call("renameConversation", { conversationId: id, title });
523
+ }),
457
524
  createConversation: (home, text, options) => this.#command(async () => {
525
+ const images = options?.images ?? [];
526
+ if (images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
527
+ throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
528
+ }
458
529
  const { conversationId: conversationId2 } = await this.#call("createConversation", {
459
530
  home,
460
531
  text,
461
532
  requestId: newRequestId(),
462
533
  ...options?.checkout === undefined ? {} : { checkout: options.checkout },
463
534
  ...options?.model === undefined ? {} : { model: options.model },
464
- ...options?.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel }
535
+ ...options?.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel },
536
+ ...images.length === 0 ? {} : { images }
465
537
  });
466
538
  await this.#switch(conversationId2);
467
539
  }),
@@ -472,7 +544,19 @@ class RemoteClient {
472
544
  if (archived && this.#side !== undefined && this.#descendsFrom(this.#side, id))
473
545
  await this.#showSide(undefined);
474
546
  }),
475
- submit: (text, whenBusy, target) => this.#command(() => this.#call("submit", { conversationId: target ?? conversationId(), text, whenBusy, requestId: newRequestId() })),
547
+ submit: (text, whenBusy, options) => this.#command(() => {
548
+ const images = options?.images ?? [];
549
+ if (images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
550
+ throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
551
+ }
552
+ return this.#call("submit", {
553
+ conversationId: options?.target ?? conversationId(),
554
+ text,
555
+ whenBusy,
556
+ requestId: options?.requestId ?? newRequestId(),
557
+ ...images.length === 0 ? {} : { images }
558
+ });
559
+ }),
476
560
  compact: (instructions) => this.#command(() => this.#call("compact", { conversationId: conversationId(), ...instructions === undefined ? {} : { instructions } })),
477
561
  abort: (target) => {
478
562
  const targetConversation = target ?? this.#current;
@@ -511,17 +595,39 @@ class RemoteClient {
511
595
  await this.#call("submit", { conversationId: forked, text: prompt, whenBusy: "followUp", requestId: newRequestId() });
512
596
  }),
513
597
  answer: (kind, requestId, answers, target) => this.#command(() => this.#call("answer", { conversationId: target ?? conversationId(), kind, requestId, answers })),
514
- changes: () => {
515
- const call = this.#commands.then(() => this.#call("changes", { conversationId: conversationId() }));
516
- this.#commands = call.then(() => {}, () => {});
517
- return call;
518
- },
519
- createPairing: () => {
520
- const pairing = this.#commands.then(() => this.#call("createPairing", {}));
521
- this.#commands = pairing.then(() => {}, () => {});
522
- return pairing;
523
- },
524
- revokeDevice: (publicKey) => this.#command(() => this.#call("revokeDevice", { publicKey }))
598
+ changes: () => this.#request(() => this.#call("changes", { conversationId: conversationId() })),
599
+ createPairing: () => this.#request(() => this.#call("createPairing", {})),
600
+ revokeDevice: (publicKey) => this.#command(() => this.#call("revokeDevice", { publicKey })),
601
+ logout: (providerId) => this.#command(() => {
602
+ if (!hostSupportsCall(this.#hostMinor, "logout")) {
603
+ throw new Error("The host is too old for this (needs protocol 5.1); run `pinomad upgrade` on the host");
604
+ }
605
+ return this.#call("logout", { providerId });
606
+ }),
607
+ updateSettings: (patch) => this.#command(() => {
608
+ if (!hostSupportsCall(this.#hostMinor, "updateSettings")) {
609
+ throw new Error("The host is too old for this (needs protocol 5.2); run `pinomad upgrade` on the host");
610
+ }
611
+ return this.#call("updateSettings", { patch });
612
+ }),
613
+ createAgent: (role) => this.#request(async () => {
614
+ if (!hostSupportsCall(this.#hostMinor, "createAgent")) {
615
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
616
+ }
617
+ await this.#call("createAgent", { role });
618
+ }),
619
+ updateAgent: (role) => this.#request(async () => {
620
+ if (!hostSupportsCall(this.#hostMinor, "updateAgent")) {
621
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
622
+ }
623
+ await this.#call("updateAgent", { role });
624
+ }),
625
+ deleteAgent: (name) => this.#request(async () => {
626
+ if (!hostSupportsCall(this.#hostMinor, "deleteAgent")) {
627
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
628
+ }
629
+ await this.#call("deleteAgent", { name });
630
+ })
525
631
  };
526
632
  }
527
633
  async#switch(id) {