@inobit/pi-instabox 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.1] - 2026-11-09
4
+
5
+ - feat: expose desktop tools as deferred so they stay out of the codemode description
6
+ - feat: derive the desktop discovery hint from the remote `initialize` instructions (first line, truncated to 250 chars, namespace-description fallback) so box upgrades carry through without code changes
7
+
3
8
  ## [0.1.0] - 2026-11-09
4
9
 
5
10
  - feat: nine `instabox_desktop_*` tools generated from the target box's remote `tools/list`, each taking an optional `box` parameter and returning `[box=<name>]` as the first line of every result
package/README.md CHANGED
@@ -7,7 +7,7 @@ Drive instabox sandboxes from Pi sessions: nine `instabox_desktop_*` computer-us
7
7
  - **Every call names its box**: an optional `box` parameter (or the five-level target resolution chain) decides which sandbox runs, and the first line of every successful result is `[box=<name>]`
8
8
  - **No silent fallback**: a mistyped or ambiguous target is an error listing candidates; only a stale `defaultBox` degrades (with one warning per session)
9
9
  - **Schemas come from the remote**: tool definitions are generated from the target box's `tools/list` and cached per box, so the load-time declarations need no ssh
10
- - **Never touches your tool selection**: desktop tools are `exposure: "codemode"` and the extension never calls `setActiveTools`
10
+ - **Never touches your tool selection**: desktop tools are `exposure: "deferred"` — callable from codemode scripts, but neither declared to the model nor listed in codemode's description — and the extension never calls `setActiveTools`
11
11
 
12
12
  ## Requirements
13
13
 
@@ -26,7 +26,7 @@ Restart the Pi session to take effect.
26
26
 
27
27
  ## Usage
28
28
 
29
- **Desktop tools.** Each entry of the target box's remote `tools/list` becomes one Pi tool named `instabox_desktop_<remote_name>` (nine for the `instabox-computer-use` build this package targets, e.g. `instabox_desktop_click`), labelled `instabox desktop <remote_name>`, grouped in the `instabox-desktop` namespace (alias `instabox_desktop`) with `exposure: "codemode"`.
29
+ **Desktop tools.** Each entry of the target box's remote `tools/list` becomes one Pi tool named `instabox_desktop_<remote_name>` (nine for the `instabox-computer-use` build this package targets, e.g. `instabox_desktop_click`), labelled `instabox desktop <remote_name>`, grouped in the `instabox-desktop` namespace (alias `instabox_desktop`) with `exposure: "deferred"`: registered and callable from a codemode script, but not declared to the model and not listed in codemode's description, so they take nothing out of its 3000-token budget. A script finds them through the namespace — `describeNamespace("instabox-desktop")` for the whole group, `searchTools("instabox")` for a BM25 search over the same text (the namespace description and instructions are part of the indexed text, so a search for the namespace name surfaces the group's tools) — and then calls them by name (`tools.instabox_desktop_click({ … })`).
30
30
 
31
31
  Every tool takes an optional `box` parameter. When it is omitted, the target is resolved in this order, first match wins:
32
32
 
@@ -40,13 +40,13 @@ Levels 1–3 are strict: an unknown name is an error and a message naming severa
40
40
 
41
41
  **`/instabox-use [box]`** — set or show this session's target box. Without arguments it prints the current target and the candidate boxes. An unknown name is refused and the previous target is kept. The target is stored as a custom session entry (never injected into the LLM context, scoped to the current branch, so `/tree` switches and `--continue` restore it). If no desktop tool is registered yet (cold start, no cache), the command also wakes the box, fetches `tools/list` and registers the tools — this is the retry path when the box was not running at session start.
42
42
 
43
- **`/instabox-status`** — one-shot diagnostics: config summary (`defaultBox`, `devToolsBox`, `autoInstallDevTools`, `remotePath` — `devToolsBox` there is only the DevTools default box, not the switch), session target, resolved target and how it was resolved, registered `instabox_desktop_*` tools with their exposure, which box's cached template the declarations came from (`tool declarations: from the cached template of box "…"`), active tools, codemode state, and the MCP servers registered by extensions — `instabox-devtools` appears in that last list only while the DevTools server is on; the switch itself is reported by `/instabox-devtools status` (see [DevTools MCP](#devtools-mcp)).
43
+ **`/instabox-status`** — one-shot diagnostics: config summary (`defaultBox`, `devToolsBox`, `autoInstallDevTools`, `remotePath` — `devToolsBox` there is only the DevTools default box, not the switch), session target, resolved target and how it was resolved, registered `instabox_desktop_*` tools with their exposure (`deferred`), which box's cached template the declarations came from (`tool declarations: from the cached template of box "…"`), active tools, codemode state, and the MCP servers registered by extensions — `instabox-devtools` appears in that last list only while the DevTools server is on; the switch itself is reported by `/instabox-devtools status` (see [DevTools MCP](#devtools-mcp)).
44
44
 
45
45
  **`/instabox-devtools [status|on [box]|off|reset]`** — the runtime toggle for the remote DevTools MCP server: bare (or `status`) shows the switch, the name of the server registered in this session, the box bound to this session and the box the next `on` would use with where that box comes from (`session` / `flag` / `config`); `on [box]` registers or replaces it and, when a box is given, binds it to this session; `off` unregisters it for the rest of the session without clearing that binding; `reset` clears the session binding only. Details in [DevTools MCP](#devtools-mcp).
46
46
 
47
47
  **Where the tools come from.** On load, a cache hit registers the desktop tools silently — no ssh, no notification. Otherwise the session start decides in the background: with a cache it re-validates the box the declarations came from (session target → `defaultBox` → most recently fetched box, each only if that box has a cache entry); with no cache it lists boxes and takes the first box in `state === "running"`, or — when nothing is running — the first box in the list. **Connecting wakes the box:** every connection, desktop tool calls and this background sync alike, first makes sure the box is running (`instabox wake`, then `instabox start`, then `instabox resume`), so a paused box is woken in the background — no progress UI, the session is not blocked — instead of being skipped. Success is silent: the tools simply appear. You only see a warning when there is no usable sandbox at all (`instabox ls` empty or failing) or when the sync itself fails; both are once per session and the latter names `/instabox-use <box>`.
48
48
 
49
- **codemode prerequisite.** `codemode` exposure means the tools are registered and callable, but they are not declared to the model directly — they are listed in codemode's description with their TypeScript declarations (inside codemode's shared 3000-token budget), so a codemode script calls them by name (`tools.instabox_desktop_click({ … })`). `describeTool` / `searchTools` / `describeNamespace` are needed for the DevTools MCP tools only, whose names never enter that description (see [DevTools MCP](#devtools-mcp)). The extension never activates codemode for you, and it only judges the state once desktop tools are registered (at session start, and on every `/instabox-status`). If codemode is inactive and no `codemode`-exposure MCP server exists, you get one warning per session with the fix: start with `pi --tools +codemode`, or set `"defaultTools": ["+codemode"]` in settings. If the session has no `codemode` tool at all (filtered out by `--tools` / `--exclude-tools`, or a built-in extension replaced), a different one-time warning says the desktop tools are not callable in this session. With the DevTools server enabled (`--instabox-devtools`, or `/instabox-devtools on`), Pi activates codemode by itself (see below), so neither warning appears. `/instabox-use` and `/instabox-status` keep working either way.
49
+ **codemode prerequisite.** `deferred` exposure means the tools are registered and callable from a codemode script, but never declared to the model and never listed in codemode's description: deferred tools are left out of that description entirely, so they do not touch its shared 3000-token budget and the description does not change while tools register. The model still has to know that they exist, so the extension writes one system-prompt section at `before_agent_start` (tag `instabox-desktop`, only while desktop tools are registered): one intro line plus a `- instabox-desktop (codemode): <description>` line, the same shape Pi uses for MCP servers. That description is **not** a constant: it is generated from remote data — the first line of the remote `initialize` `instructions`, truncated to 250 characters, falling back to the packaged namespace description when the remote sends none. A remote upgrade (or any refetch, such as the 168-hour TTL) therefore carries the new text over to the next registration (load-time cache hit, the `session_start` background sync, `/instabox-use`) with no code change. It only changes when the remote data changes, so no per-turn system-prompt patching happens and the prompt prefix cache stays valid. In a script, `describeNamespace("instabox-desktop")` returns the namespace and the names of its tools, `describeTool(name)` returns one tool's full declaration (the `ALL_TOOLS` entries carry declarations too), and `searchTools("instabox")` ranks the same tools by BM25. codemode itself still has to be active — only a script can run these tools — and the extension never activates it for you, and it only judges the state once desktop tools are registered (at session start, and on every `/instabox-status`). If codemode is inactive and no `codemode`-exposure MCP server exists, you get one warning per session with the fix: start with `pi --tools +codemode`, or set `"defaultTools": ["+codemode"]` in settings. If the session has no `codemode` tool at all (filtered out by `--tools` / `--exclude-tools`, or a built-in extension replaced), a different one-time warning says the desktop tools are not callable in this session. With the DevTools server enabled (`--instabox-devtools`, or `/instabox-devtools on`), Pi activates codemode by itself (see below), so neither warning appears. `/instabox-use` and `/instabox-status` keep working either way.
50
50
 
51
51
  ## Configuration
52
52
 
@@ -191,7 +191,7 @@ Because this server has `codemode` exposure, Pi activates codemode automatically
191
191
 
192
192
  - **Host floor:** `pi-coding-agent >= 0.99.0` (see Requirements). The package pins the types it is compiled against, so an upgrade shows up as a `check` failure rather than a runtime surprise.
193
193
  - **Your tool selection is yours:** the extension never calls `setActiveTools`, never writes `settings.json` and never edits `defaultTools`. It only reports the codemode state and the command that fixes it.
194
- - **codemode inactive means uncallable:** the tools are registered but unreachable while codemode is off, and the model cannot see their names outside codemode. See the prerequisite in Usage.
194
+ - **codemode inactive means uncallable:** the tools are registered but unreachable while codemode is off. Nothing lists them either: the model learns their names by searching (`describeNamespace("instabox-desktop")` / `searchTools("instabox")`). See the prerequisite in Usage.
195
195
  - **Tool sets follow the template, not the extension:** definitions come from the cached `tools/list` of the box they came from. Different boxes may run different `instabox-computer-use` versions, so the set and the schemas can differ per box. Validation is one handshake per session start: the same remote version keeps the cached tools as they are, a different version refetches `tools/list` and rewrites the entry, and a 168-hour TTL refetches on its own. Names are stable (`instabox_desktop_<remote_name>`), so a codemode script that names a tool keeps working across versions.
196
196
  - **Quiet by design:** a successful background sync says nothing at all — the tools simply show up, and the transcript carries no notification about the declaration. The only session-start warnings are "no usable sandbox" (`ls` empty or failing), a failed sync naming `/instabox-use <box>`, and a misconfigured DevTools switch (box missing, box not in `ls`, or `--instabox-devtools-box` without `--instabox-devtools`). Internal `[pi-instabox] …` stderr output and the `debug`-level half of the JSONL log (see [Logs](#logs)) are off by default; set `PI_INSTABOX_DEBUG=1` to see wakes, spawn argv and handshake internals. Box selection needs no switch: each tool call writes its `resolve` record at `info`.
197
197
  - **A fully paused fleet is still woken:** when no box is running, the first box from `instabox ls` is woken in the background and its template fetched — the wake happens on connect, so a cached box that is paused gets woken for the version check too; a wake failure degrades to that one warning, and the session is never blocked or held up.
package/README.zh-CN.md CHANGED
@@ -7,7 +7,7 @@
7
7
  - **每次调用都点名箱**:可选的 `box` 参数(或五级目标解析链)决定哪台沙箱执行,且每条成功结果首行固定为 `[box=<name>]`
8
8
  - **不静默降级**:箱名写错或有歧义就是错误并列出候选;唯一会降级的是失效的 `defaultBox`(每会话一条 warning)
9
9
  - **schema 来自远端**:工具定义由目标箱的 `tools/list` 生成并按箱缓存,加载期的工具声明不需要 ssh
10
- - **不碰你的工具选择集**:桌面工具是 `exposure: "codemode"`,扩展从不调用 `setActiveTools`
10
+ - **不碰你的工具选择集**:桌面工具是 `exposure: "deferred"`——可从 codemode 脚本调用,但不声明给模型、也不列进 codemode 描述——扩展从不调用 `setActiveTools`
11
11
 
12
12
  ## 环境要求
13
13
 
@@ -26,7 +26,7 @@ pi install npm:@inobit/pi-instabox
26
26
 
27
27
  ## 用法
28
28
 
29
- **桌面工具。** 目标箱远端 `tools/list` 的每个条目变成一个 pi 工具,名为 `instabox_desktop_<远端名>`(本包面向的 `instabox-computer-use` 是九个,如 `instabox_desktop_click`),label 为 `instabox desktop <远端名>`,统一挂在 `instabox-desktop` 命名空间(别名 `instabox_desktop`)下,`exposure: "codemode"`。
29
+ **桌面工具。** 目标箱远端 `tools/list` 的每个条目变成一个 pi 工具,名为 `instabox_desktop_<远端名>`(本包面向的 `instabox-computer-use` 是九个,如 `instabox_desktop_click`),label 为 `instabox desktop <远端名>`,统一挂在 `instabox-desktop` 命名空间(别名 `instabox_desktop`)下,`exposure: "deferred"`:已注册、可从 codemode 脚本调用,但不声明给模型、也不列进 codemode 描述,因此不占那 3000 token 预算。脚本经命名空间找到它们——`describeNamespace("instabox-desktop")` 取整组,`searchTools("instabox")` 在同一份文本上做 BM25 检索(命名空间的 description 与 instructions 都在检索文本里,所以只搜命名空间名就能命中这一组)——再按名字调用(`tools.instabox_desktop_click({ … })`)。
30
30
 
31
31
  每个工具都有一个可选 `box` 参数。不传时按下面的顺序解析目标,先命中先停:
32
32
 
@@ -40,13 +40,13 @@ pi install npm:@inobit/pi-instabox
40
40
 
41
41
  **`/instabox-use [box]`**——设置或查看本会话的目标箱。不带参数时打印当前目标与候选箱;箱名不存在则拒绝,原目标保留。目标以一条自定义会话条目存储(不进 LLM 上下文,按分支生效,因此 `/tree` 切分支与 `--continue` 都能恢复)。若当时还没注册任何桌面工具(冷启动、无缓存),这条命令还会唤醒该箱、拉取 `tools/list` 并注册工具——这就是会话启动时箱子没在跑的重试路径。
42
42
 
43
- **`/instabox-status`**——一次性诊断:配置摘要(`defaultBox`、`devToolsBox`、`autoInstallDevTools`、`remotePath`——那里出现的 `devToolsBox` 只是 DevTools 的默认箱,不是开关)、会话目标、解析出的目标及其来源、已注册的 `instabox_desktop_*` 工具与 exposure、当前声明来自哪台箱的缓存模板(`tool declarations: from the cached template of box "…"`)、active tools、codemode 状态,以及扩展注册的 MCP server——`instabox-devtools` 只在 DevTools server 开着时出现在最后这一段;开关本身由 `/instabox-devtools status` 报告(见 [DevTools MCP](#devtools-mcp))。
43
+ **`/instabox-status`**——一次性诊断:配置摘要(`defaultBox`、`devToolsBox`、`autoInstallDevTools`、`remotePath`——那里出现的 `devToolsBox` 只是 DevTools 的默认箱,不是开关)、会话目标、解析出的目标及其来源、已注册的 `instabox_desktop_*` 工具与 exposure(显示为 `deferred`)、当前声明来自哪台箱的缓存模板(`tool declarations: from the cached template of box "…"`)、active tools、codemode 状态,以及扩展注册的 MCP server——`instabox-devtools` 只在 DevTools server 开着时出现在最后这一段;开关本身由 `/instabox-devtools status` 报告(见 [DevTools MCP](#devtools-mcp))。
44
44
 
45
45
  **`/instabox-devtools [status|on [box]|off|reset]`**——远端 DevTools MCP server 的运行期开关:不带参数(或 `status`)显示开关、本会话已注册的 server、会话绑定的箱,以及下一次将使用的箱及其来源(`session` / `flag` / `config`);`on [box]` 注册或替换,给了箱名就同时把它绑到本会话;`off` 在本会话内注销它、但**不清除**该绑定;`reset` 只清会话绑定。细节见 [DevTools MCP](#devtools-mcp)。
46
46
 
47
47
  **工具声明的来源。**加载期命中缓存就静默注册桌面工具——零 ssh、零提示。否则会话启动在后台替你决定:有缓存时对「声明来源」那台箱做一次版本校验(会话目标 → `defaultBox` → 缓存里最近拉取的那台,均要求该箱有缓存条目);没有缓存时先 `ls`,取**第一台 `state === "running"`** 的箱,一台 running 都没有时取列表第一台。**连接即唤醒**:无论桌面工具调用还是这个后台同步,建连前都先确保箱子在跑(`instabox wake`、再 `instabox start`、再 `instabox resume`),因此暂停的箱会在后台被唤醒——不显示进度、不阻塞会话——而不是被跳过。成功一律静默:工具出现就是唯一的成功信号。只有真的同步不了才提示:`instabox ls` 为空或失败 → 一条「没有可用沙箱」;同步本身失败 → 一条带 `/instabox-use <box>` 的提示,都是每会话一次。
48
48
 
49
- **codemode 前提。** `codemode` exposure 的含义是:工具已注册、可调用,但不直接声明给模型——它们会带 TypeScript 声明列在 codemode 的描述里(与其他 codemode 工具共享那 3000 token 预算),脚本按名字直接调用即可(`tools.instabox_desktop_click({ … })`)。`describeTool`、`searchTools`、`describeNamespace` 只用于 DevTools 的 MCP 工具,那些工具的名字不进这段描述(见 [DevTools MCP](#devtools-mcp))。扩展不会替你激活 codemode,且只在**已注册桌面工具**时才判定状态(会话启动一次,`/instabox-status` 每次实时读)。若 codemode 未激活、且会话里没有 `codemode` exposure 的 MCP server,你会收到每会话一条 warning 并附带补救命令:用 `pi --tools +codemode` 启动,或在 settings 里设 `"defaultTools": ["+codemode"]`。若本会话根本没有 `codemode` 工具条目(被 `--tools` / `--exclude-tools` 剔除,或内置扩展被替换),则用另一条一次性文案明说桌面工具在本会话不可调用。配置了 DevTools server 时(`--instabox-devtools` 或 `/instabox-devtools on`),pi 会自动激活 codemode(见下文),因此两条提示都不会出现。两种情况下 `/instabox-use` 与 `/instabox-status` 都可用。
49
+ **codemode 前提。** `deferred` exposure 的含义是:工具已注册、可从 codemode 脚本调用,但永不声明给模型、也永不列进 codemode 描述——deferred 工具被完全排除在那段描述之外,因此不占它那 3000 token 共享预算,工具陆续注册也不会让描述变化。模型仍需知道它们存在,所以扩展在 `before_agent_start` 写一段 system prompt section(tag `instabox-desktop`,只在桌面工具已注册时写):一行 intro 加一行 `- instabox-desktop (codemode): <描述>`,与 pi 写给 MCP 工具的 `mcp_servers` 段同款。那行描述**不是**写死的常量——它按远端数据生成:取远端 `initialize` 的 `instructions` 首行(截断到 250 字符),远端没给时回退到包内命名空间描述——因此远端升版(或任意一次重拉,例如 168 小时 TTL 到期)后,下一次注册(加载期缓存命中、`session_start` 后台同步、`/instabox-use`)就自动换上新描述,不需要改代码。内容只在远端数据变化时变,因此不会每轮产生 system prompt 补丁,也不破坏 prompt 前缀缓存。脚本里用 `describeNamespace("instabox-desktop")` 拿到命名空间与整组的工具名,用 `describeTool(name)` 取单个工具的完整声明(`ALL_TOOLS` 里也带声明),或用 `searchTools("instabox")` 做 BM25 检索。codemode 本身仍须处于激活态——只有脚本才跑得动这些工具——扩展不会替你激活 codemode,且只在**已注册桌面工具**时才判定状态(会话启动一次,`/instabox-status` 每次实时读)。若 codemode 未激活、且会话里没有 `codemode` exposure 的 MCP server,你会收到每会话一条 warning 并附带补救命令:用 `pi --tools +codemode` 启动,或在 settings 里设 `"defaultTools": ["+codemode"]`。若本会话根本没有 `codemode` 工具条目(被 `--tools` / `--exclude-tools` 剔除,或内置扩展被替换),则用另一条一次性文案明说桌面工具在本会话不可调用。配置了 DevTools server 时(`--instabox-devtools` 或 `/instabox-devtools on`),pi 会自动激活 codemode(见下文),因此两条提示都不会出现。两种情况下 `/instabox-use` 与 `/instabox-status` 都可用。
50
50
 
51
51
  ## 配置
52
52
 
@@ -191,7 +191,7 @@ const ns = describeNamespace("mcp__instabox_devtools"); // { name, tools }
191
191
 
192
192
  - **宿主门槛:**`pi-coding-agent >= 0.99.0`(见环境要求)。本包钉死编译所用类型,升级只会体现为 `check` 失败,而不是运行期意外。
193
193
  - **工具选择集归你:**扩展从不调用 `setActiveTools`,不写 `settings.json`,也不改 `defaultTools`;它只报告 codemode 状态与补救命令。
194
- - **codemode 未激活=不可调用:**工具已注册,但 codemode 关掉时模型够不到它们,在 codemode 之外也看不到工具名。见「用法」里的前提说明。
194
+ - **codemode 未激活=不可调用:**工具已注册,但 codemode 关掉时模型够不到它们;也没有任何列表会提到它们——模型得先自己搜(`describeNamespace("instabox-desktop")` / `searchTools("instabox")`)才知道名字。见「用法」里的前提说明。
195
195
  - **工具集跟随模板而非扩展:**定义来自该箱缓存的 `tools/list`。不同箱可能跑不同版本的 `instabox-computer-use`,因此工具集与 schema 可能逐箱不同。校验口径是每次会话启动一次握手:远端版本相同则沿用缓存的工具、不动磁盘,不同则重拉并重写该箱条目,另有 168 小时 TTL 单独触发重拉。工具名稳定(`instabox_desktop_<远端名>`),所以写死了名字的 codemode 脚本跨版本仍可用。
196
196
  - **默认安静:**后台声明成功不提示——成功路径没有任何通知,工具直接出现。会话启动只有三种提示:`ls` 空/失败的「没有可用沙箱」、同步失败并给出 `/instabox-use <box>`,以及 DevTools 开关配置有误(箱名缺失、箱不在 `ls` 里、或只给了 `--instabox-devtools-box` 没给 `--instabox-devtools`,见 [DevTools MCP](#devtools-mcp))。内部 `[pi-instabox] …` 的 stderr 输出与 JSONL 日志的 `debug` 级记录(见[日志](#日志))默认关闭,设 `PI_INSTABOX_DEBUG=1` 才能看到唤醒、spawn 参数与握手内部细节;**选箱不需要开关**:每次工具调用都会以 `info` 落一条 `resolve`。
197
197
  - **全部暂停也会被拉起:**没有 running 的箱时,取 `instabox ls` 第一台在后台唤醒并拉模板——唤醒发生在建连这一步,所以缓存命中但箱已暂停时也会为版本校验把它唤醒;唤醒失败降级为那一条提示,会话不会被阻塞或拖住。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inobit/pi-instabox",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Drive instabox sandboxes from Pi: `instabox_desktop_*` computer-use tools with per-call sandbox targeting, plus a config-driven remote `chrome-devtools-mcp` server.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -42,6 +42,11 @@
42
42
  * 6. `session_shutdown`:`pool.disposeAll()` 收掉本实例持有的远端连接(否则换会话 / `/reload`
43
43
  * 之后会残留 ssh 子进程),并丢掉已失效的 ctx;随后 `setLogger(undefined)` 复位日志,
44
44
  * 不留进程内残留(下一个会话重新装配时再建)。
45
+ * 7. `before_agent_start`:桌面工具是 `deferred`(既不声明、也不进 codemode 描述),所以每次 run
46
+ * 之前把一段发现提示写进 system prompt 的 `instabox-desktop` section
47
+ * ({@link buildDiscoveryHint}),仅在确实注册过桌面工具时写。提示内容**由远端数据生成**:
48
+ * 每次注册桌面工具时用当前模板的 `tools.length` 与 `initialize` 的 `instructions` 重新算一次,
49
+ * 同一份模板内保持恒定(不产生 mid-conversation 的 system 补丁)。
45
50
  *
46
51
  * 会话状态一律**实时读** `ctx.sessionManager.getBranch()`:解析链第 2 级是「本轮最后一条 user
47
52
  * 消息」,缓存它会让这一级永远读到会话开始时的快照而失效;因此每次工具调用都用工具自带的
@@ -66,7 +71,7 @@ import { buildMcpServerConfig, ensureHelperScript } from "./devtools.ts";
66
71
  import { createTargetRendererWithHostText } from "./entry-render.ts";
67
72
  import { type LogLevel, type Logger, createFileLogger, logEvent, setLogger } from "./log.ts";
68
73
  import { type Pool, createPool } from "./pool.ts";
69
- import type { RemoteClient, RemoteTool } from "./remote.ts";
74
+ import type { RemoteClient } from "./remote.ts";
70
75
  import { resolveBox, type ResolveResult } from "./resolve.ts";
71
76
  import {
72
77
  DEVTOOLS_TARGET_ENTRY,
@@ -76,8 +81,8 @@ import {
76
81
  readTarget,
77
82
  targetEntryData,
78
83
  } from "./session-target.ts";
79
- import { createTemplateStore, nodeReadText, readCache } from "./template.ts";
80
- import { buildToolDefinitions } from "./tools.ts";
84
+ import { type CachedTemplate, createTemplateStore, nodeReadText, readCache } from "./template.ts";
85
+ import { buildDiscoveryHint, buildToolDefinitions } from "./tools.ts";
81
86
 
82
87
  /** 无 `instabox` CLI 时的唯一提示(每会话一条;方案 §7 验收 1)。 */
83
88
  const NO_CLI_MESSAGE = "未找到 instabox CLI,instabox 扩展已禁用";
@@ -361,6 +366,11 @@ export default async function instaboxExtension(
361
366
  const notified = new Set<string>();
362
367
  /** 已注册的桌面工具名 → exposure(`/instabox-status` 与「是否有工具」判定都读它) */
363
368
  const registeredTools = new Map<string, ToolExposure>();
369
+ /**
370
+ * 当前发现提示(`before_agent_start` 写入);每次注册桌面工具时按当时模板的
371
+ * `tools.length` 与 `serverInstructions` 重算,因此不必在事件里回读缓存。
372
+ */
373
+ let discoveryHint: string | undefined;
364
374
  /** 当前工具声明来自哪台箱的模板缓存(**不是**解析链结果:实际目标在调用时解析) */
365
375
  let declaredFromBox: string | undefined;
366
376
  /** 加载期产生、等 `session_start` 才有的 UI 出口 */
@@ -456,9 +466,9 @@ export default async function instaboxExtension(
456
466
  }
457
467
 
458
468
  /** 注册某箱模板里的工具(同名替换由宿主保证),返回本次声明的条数。 */
459
- function registerDesktopTools(remoteTools: readonly RemoteTool[], box: string): number {
469
+ function registerDesktopTools(template: CachedTemplate, box: string): number {
460
470
  const definitions = buildToolDefinitions({
461
- remoteTools,
471
+ remoteTools: template.tools,
462
472
  pool,
463
473
  resolve: resolveTarget,
464
474
  listBoxes: () => boxLister.list(),
@@ -469,6 +479,12 @@ export default async function instaboxExtension(
469
479
  registeredTools.set(definition.name, definition.exposure ?? "direct");
470
480
  }
471
481
  declaredFromBox = box;
482
+ // 发现提示跟着这次注册的模板走:远端改了 `instructions` 而下一次注册用的是新模板,
483
+ // 下一次 run 就写新文案;模板不变则文案不变。
484
+ discoveryHint = buildDiscoveryHint({
485
+ toolCount: template.tools.length,
486
+ serverInstructions: template.serverInstructions,
487
+ });
472
488
  return definitions.length;
473
489
  }
474
490
 
@@ -602,7 +618,7 @@ export default async function instaboxExtension(
602
618
  return;
603
619
  }
604
620
  if (!fetched.ok) debug(`instabox: version check failed for "${cachedBox}", using cached tools: ${fetched.error}`);
605
- registerDesktopTools(template.tools, cachedBox);
621
+ registerDesktopTools(template, cachedBox);
606
622
  return;
607
623
  }
608
624
 
@@ -623,7 +639,7 @@ export default async function instaboxExtension(
623
639
  notifySyncFailure(box, fetched.error);
624
640
  return;
625
641
  }
626
- registerDesktopTools(fetched.template.tools, box);
642
+ registerDesktopTools(fetched.template, box);
627
643
  }
628
644
 
629
645
  /**
@@ -645,7 +661,7 @@ export default async function instaboxExtension(
645
661
  );
646
662
  return;
647
663
  }
648
- const count = registerDesktopTools(template.tools, box);
664
+ const count = registerDesktopTools(template, box);
649
665
  if (reason === undefined) {
650
666
  ctx.ui.notify(`instabox: registered ${count} desktop tool(s) for box "${box}"`, "info");
651
667
  return;
@@ -657,7 +673,7 @@ export default async function instaboxExtension(
657
673
  const loadTarget = cachedTargetBox();
658
674
  if (loadTarget !== undefined) {
659
675
  const cached = templateStore.get(loadTarget);
660
- if (cached !== undefined) registerDesktopTools(cached.tools, loadTarget);
676
+ if (cached !== undefined) registerDesktopTools(cached, loadTarget);
661
677
  }
662
678
  logEvent("load.assembled", {
663
679
  cacheHit: declaredFromBox !== undefined,
@@ -994,6 +1010,21 @@ export default async function instaboxExtension(
994
1010
  (timer as unknown as { unref?: () => void }).unref?.();
995
1011
  }
996
1012
 
1013
+ // —— 事件:每次 run 之前补一段「怎么发现桌面工具」的提示 ——
1014
+ /**
1015
+ * 桌面工具是 `deferred`:既不在工具列表里,也不进 codemode 描述,宿主也不会为非 MCP 的工具
1016
+ * 提示它们存在。所以由本扩展在**每次 run 之前**把发现提示写进 system prompt。
1017
+ *
1018
+ * - 只在确实注册过桌面工具时才写:还没注册就写等于空承诺;
1019
+ * - 文案在注册时由远端数据生成(`discoveryHint`),同一份模板内不变,因此不会产生
1020
+ * mid-conversation 的 system 补丁;
1021
+ * - 只碰 `sections`:**不**改 `selectedTools`、**不**调 `setActiveTools`。
1022
+ */
1023
+ pi.on("before_agent_start", (event) => {
1024
+ if (registeredTools.size === 0 || discoveryHint === undefined) return;
1025
+ event.systemPromptOptions.sections["instabox-desktop"] = discoveryHint;
1026
+ });
1027
+
997
1028
  // —— 事件:会话开始 ——
998
1029
  /**
999
1030
  * 本会话的启动:同步段只有内存赋值 + 通知 drain,远端与磁盘工作都在宏任务之后。
package/src/template.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * - 版本比对的 `serverInfoVersion` **只能**取自远端 `initialize` 的 `serverInfo.version`
10
10
  * (即 `instabox-computer-use` 版本),不是 `ls` 的 `template.version`。`templateId` /
11
11
  * `templateVersion` 只作为诊断字段透传保留,本模块不采集。
12
+ * - 同一次握手的 `instructions`(远端对整组工具的一句话描述)随工具一起缓存(
13
+ * `serverInstructions`):发现提示由它生成,不该在装配层另写一份文案。
12
14
  * - **绝不唤醒 box**:本模块不 import `cli.ts`,也不调用任何 `wake` / `start` / `resume`;
13
15
  * 只通过注入的 `clientFactory` 拿连接、`initialize()` + `listTools()`。`instabox ssh` 到
14
16
  * 暂停箱会直接失败,失败即返回 `ok: false` 并保留旧缓存——唤醒链归 `pool.ts`。
@@ -50,6 +52,11 @@ export interface CachedTemplate {
50
52
  templateId?: string;
51
53
  /** 诊断字段(来自 `ls`;本模块不采集远端值,只保留入参或旧缓存里的值) */
52
54
  templateVersion?: string;
55
+ /**
56
+ * 远端 `initialize` 的 `instructions`(裁掉两侧空白后的全文);远端没报时为 `undefined`
57
+ * (旧缓存没有该字段也照常可用)。发现提示取它的首行,见 `tools.buildDiscoveryHint`。
58
+ */
59
+ serverInstructions?: string;
53
60
  /** 拉取时间(ISO 8601,用于 TTL 判定) */
54
61
  fetchedAt: string;
55
62
  /** 远端 `tools/list` 原样结果(schema 未转 TypeBox) */
@@ -225,6 +232,16 @@ function parseTools(raw: unknown): RemoteTool[] | undefined {
225
232
  return tools;
226
233
  }
227
234
 
235
+ /**
236
+ * 远端 `instructions` 归一:非字符串 / 空白 → `undefined`。
237
+ * 空串不进缓存,免得发现提示里出现空描述(回退到命名空间描述更诚实)。
238
+ */
239
+ function normalizeInstructions(value: unknown): string | undefined {
240
+ if (typeof value !== "string") return undefined;
241
+ const trimmed = value.trim();
242
+ return trimmed === "" ? undefined : trimmed;
243
+ }
244
+
228
245
  /** 单个缓存条目:字段不全就整条丢弃(不拿半条缓存去注册工具)。 */
229
246
  function parseCachedTemplate(raw: unknown): CachedTemplate | undefined {
230
247
  if (!isRecord(raw)) return undefined;
@@ -239,6 +256,9 @@ function parseCachedTemplate(raw: unknown): CachedTemplate | undefined {
239
256
  if (typeof templateId === "string") entry.templateId = templateId;
240
257
  const templateVersion = raw["templateVersion"];
241
258
  if (typeof templateVersion === "string") entry.templateVersion = templateVersion;
259
+ // 旧缓存没有这个字段:缺了就用命名空间描述兜底,不算坏条目
260
+ const serverInstructions = normalizeInstructions(raw["serverInstructions"]);
261
+ if (serverInstructions !== undefined) entry.serverInstructions = serverInstructions;
242
262
  return entry;
243
263
  }
244
264
 
@@ -387,6 +407,7 @@ class FileTemplateStore implements TemplateStore {
387
407
  // 每次 ensure 都握手一次:版本校验的唯一可靠来源是远端 `serverInfo.version`
388
408
  let client: RemoteClient;
389
409
  let handshakeVersion: string;
410
+ let handshakeInstructions: string | undefined;
390
411
  try {
391
412
  // `allowWake` 只透传:没传第二参就交给工厂的默认策略
392
413
  const allowWake = options.allowWake;
@@ -394,6 +415,7 @@ class FileTemplateStore implements TemplateStore {
394
415
  const initialized = await client.initialize();
395
416
  if (isAborted(options.signal)) return abortFailure(box, cached);
396
417
  handshakeVersion = serverVersionOf(initialized);
418
+ handshakeInstructions = normalizeInstructions(initialized["instructions"]);
397
419
  } catch (error) {
398
420
  // 握手失败保留旧缓存(不写盘、不改内存),让调用方还能用上次的 schema 降级
399
421
  this.warn?.(`template ensure failed for ${box}: ${messageOf(error)}`);
@@ -428,6 +450,8 @@ class FileTemplateStore implements TemplateStore {
428
450
  fetchedAt: new Date(this.clock()).toISOString(),
429
451
  tools,
430
452
  };
453
+ // 不继承旧缓存的 instructions:这次握手没报就说明远端现在没有这句话
454
+ if (handshakeInstructions !== undefined) entry.serverInstructions = handshakeInstructions;
431
455
  const cache = this.load();
432
456
  cache.boxes[box] = entry;
433
457
  writeCache(this.fs.writeText, this.cacheFile, cache, this.warn);
package/src/tools.ts CHANGED
@@ -12,9 +12,12 @@
12
12
  * - `parameters` = `injectBoxParam(jsonSchemaToTypeBox(remote.inputSchema))`:`box` 只加在**顶层**
13
13
  * `properties`、永不进 `required`。远端自带 `box`(或顶层不是 object schema)→ 不注入、
14
14
  * 只 `warn`(解析链退到 2–5 级,方案 §4.8 ④)。
15
- * - `exposure: "codemode"`(裁决 V2:本模块连 `ExtensionAPI` 都不碰,绝不 `setActiveTools`);
16
- * 所有工具共享同一 `namespace`(`instabox-desktop`,别名 `instabox_desktop`),codemode 才会
17
- * 按组展示、`describeNamespace()` 才找得到它们。
15
+ * - `exposure: "deferred"`(裁决 V2:本模块连 `ExtensionAPI` 都不碰,绝不 `setActiveTools`):
16
+ * 与 MCP 工具同一档——**不进** codemode 描述(连计数都不占),但仍可从 codemode 脚本调用。
17
+ * 所有工具共享同一 `namespace`(`instabox-desktop`,别名 `instabox_desktop`),这个字段必须留着:
18
+ * codemode 脚本靠它 `describeNamespace()` / `searchTools({ namespace })` 才找得到声明。因为
19
+ * `deferred` 既不声明也不列描述,发现手段必须由装配层说明({@link buildDiscoveryHint},
20
+ * 由 `index.ts` 的 `before_agent_start` 写进 system prompt)。
18
21
  * - `execute`:取 `box` 参数 → 注入的 `resolve`(5 级解析链)→ `pool.get`(复用/唤醒/建连)→
19
22
  * `client.callTool(remote_name, 其余参数, signal)`。结果**首行固定** `[box=<name>]`(第一个
20
23
  * text content),远端 `content` 原样透传(含 image),`isError` 保留并追加一行修复建议。
@@ -67,6 +70,43 @@ export const DESKTOP_NAMESPACE_INSTRUCTIONS = [
67
70
  'Desktop screenshots are expensive. For browser work prefer the DevTools server when one is configured: discover it with describeNamespace("mcp__instabox_devtools") or searchTools("devtools") and only use desktop clicks/screenshots when DevTools cannot do the job.',
68
71
  ].join("\n");
69
72
 
73
+ /** 发现提示里 `(codemode): …` 那段描述的最大长度(与 pi 的 `mcp_servers` 段同口径)。 */
74
+ const DISCOVERY_DESCRIPTION_MAX_CHARS = 250;
75
+
76
+ /**
77
+ * 发现提示:装配层在每次 run 之前把它写进 system prompt 的 `instabox-desktop` section。
78
+ *
79
+ * `deferred` 的工具既不在工具列表里、也不进 codemode 描述,宿主也不会替非 MCP 的工具提示它们
80
+ * 存在,所以模型只能靠这一段知道「先 `describeNamespace()` / `searchTools()` 拿到声明,再调用」。
81
+ * 格式与 pi 自己写给 MCP 工具的 `mcp_servers` 段同款:一行 intro,加每个能力一行
82
+ * `- <namespace> (codemode): <一句话描述>`——模型已经在 MCP 段里学会读这种形状,不需要第二种写法。
83
+ *
84
+ * 描述**由远端数据生成**:取远端 `initialize` 的 `instructions` 首行(远端没报时回退到
85
+ * {@link DESKTOP_NAMESPACE_DESCRIPTION}),与 pi 给 MCP server 写的那行同源。同一份模板内文案恒定
86
+ * (不含数量、箱名、时间),只有远端真的改了 `instructions` 才会变;内容不变就不会产生
87
+ * mid-conversation 的 system 补丁,前缀缓存也就不失效。
88
+ *
89
+ * `toolCount` 是**诊断用**的入参:刻意**不**渲染进文案——工具数会随着远端版本漂移,
90
+ * 写进 section 只会制造无意义的 system 补丁。
91
+ */
92
+ export function buildDiscoveryHint(input: { toolCount: number; serverInstructions?: string }): string {
93
+ return [
94
+ "instabox desktop tools whose declarations are not loaded. Call them from codemode scripts.",
95
+ `- ${DESKTOP_NAMESPACE_NAME} (codemode): ${discoveryDescription(input.serverInstructions)}`,
96
+ ].join("\n");
97
+ }
98
+
99
+ /**
100
+ * `instructions` → 一行描述:取首行,超过 {@link DISCOVERY_DESCRIPTION_MAX_CHARS} 时按 pi 的
101
+ * `truncate` 口径裁到总长 250(保留 249 字符 + `…`)。空 / 缺失时回退到命名空间描述。
102
+ */
103
+ function discoveryDescription(serverInstructions: string | undefined): string {
104
+ const firstLine = (serverInstructions ?? "").split("\n", 1)[0]?.trim() ?? "";
105
+ const text = firstLine === "" ? DESKTOP_NAMESPACE_DESCRIPTION : firstLine;
106
+ if (text.length <= DISCOVERY_DESCRIPTION_MAX_CHARS) return text;
107
+ return `${text.slice(0, DISCOVERY_DESCRIPTION_MAX_CHARS - 1).trimEnd()}…`;
108
+ }
109
+
70
110
  /** `isError` 结果追加的一行:说清「没有自动重试、也没有换箱」,给出两条可执行补救。 */
71
111
  const IS_ERROR_REMEDY =
72
112
  'instabox: the remote tool reported an error (shown above unchanged). Nothing was retried and no other box was used — pass box="<name>" to target a different box, or run /instabox-status to review targets.';
@@ -156,7 +196,7 @@ function buildToolDefinition(
156
196
  label: desktopToolLabel(remote.name),
157
197
  description: descriptionOf(remote),
158
198
  parameters: schema,
159
- exposure: "codemode",
199
+ exposure: "deferred",
160
200
  namespace,
161
201
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
162
202
  const startedAt = Date.now();