# Bounded Project / Session Catalog 与 Workspace Control

状态：协议设计 + `prototypes/wearable_shell/catalog.py` 离线可执行参考。没有注册新的 production route，没有让设备直接调用管理 HTTP API。

## 当前能力与缺口

Gateway 已有 `GET /api/projects`、`GET /api/projects/{project_id}/sessions`、统一 Pi/Codex resume/input/settings 和 `POST /devices/{device_id}/target`。`AgentAdapter` 没有 create_session。ProjectRegistry.add 要求存在的绝对目录并 resolve；现在没有 allowed roots/discovery 安全策略。

已有 sessions HTTP 接口会汇总两种 adapter 的全部结果；CodexAdapter 会翻完 `thread/list(limit=100)`，Pi SDK host 调 `SessionManager.list(cwd)`。**不能原样转发这些响应给 ESP32，也不能把 `list_sessions()[:4]` 说成端到端有界。** 新 controller 需要分页/近期索引与时间预算；原 HTTP 接口可以保持兼容。

## 协议版本1（0.6b，拟新增）

沿用已认证设备 WebSocket。能力协商使用可选 `catalog_capabilities` 消息 `{v:1,max_items:4,max_bytes:4096}`，旧 Gateway 不发送时新设备隐藏 remote picker 并保留 existing selected target；新 Gateway 只有在设备声明支持后发送 catalog。保持0.5的其他消息与 Speech wire protocol 不变。

```json
{"v":1,"type":"project_catalog_get","request_id":"bootA-1","cursor":""}
{"v":1,"type":"session_catalog_get","request_id":"bootA-2","project":"esp32-dev","cursor":"","order":"recent"}
```

示意响应（token为占位，实际不可猜测；时间为UTC seconds）：

```json
{
  "v":1,"type":"session_catalog","request_id":"bootA-2",
  "snapshot":"opaque-snapshot","project":"esp32-dev","ttl_ms":60000,
  "items":[
    {"project":"esp32-dev","name":"Speech debugging","agent":"codex",
     "session_id":"session-a17","state":"RUNNING","updated_at":1791108000},
    {"project":"esp32-dev","name":"界面布局","agent":"pi",
     "session_id":"session-b29","state":"DORMANT","updated_at":1791107000}
  ],
  "next":"opaque-cursor","truncated":false
}
```

project_catalog 使用同样 envelope、project=""，items只有 project/name。session_catalog 不包含 cwd、prompt、activity、final_text、model list 或凭据。名称是显示字段；`project`、`agent`、`session_id` 是精确身份，绝不对 ID 截断/Unicode规范化。

| Bound | 限制与拒绝策略 |
|---|---|
| 完整 wire frame | **4096 UTF-8 bytes**，含 JSON escaping/所有字段；编码完再检查，超限是 bounded error，不切半个 JSON |
| 每页 items | 固定最多4；设备固定数组4，屏幕每次3行；Next / Back 是本地行 |
| display name | 最多72 UTF-8 bytes；NFC，去控制/双向控制字符，省略号用3个ASCII点；像素宽度另测 |
| project ID | 最多64 ASCII bytes；production 继续 Project.id 的严格 `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`；参考模型的通用 identifier 是较宽的超集，不能代替 production Project validator |
| session ID | 最多160 ASCII bytes，沿用现有 target允许字符；不支持的ID明确返回 unsupported，不能静默截短 |
| request_id | 最多32 ASCII bytes，boot nonce+单调计数；每个 picker只有一个pending |
| snapshot/cursor | snapshot24随机base64url字符；cursor≤64，MAC绑定 snapshot+offset；不得带路径或offset自行越界 |
| expiry | 60s，按Gateway单调clock；device按接收单调clock倒计时，RTC不参与有效期 |
| decoder | 固件计划depth≤4、tokens≤192；重复key/未知字段/错误类型/无效UTF-8/超深全部拒绝；先validate，再一次替换page |
| memory budget | 建议接收4097 + token arena≤3072 + 双page各≤1536 + context/target≤1024，总计≤12KiB；这是集成预算，须static_assert和heap测量，不是本轮实测固件数据 |
| Gateway cache | 16 snapshots，全局每份最多200轻量行；60s TTL；满则BUSY、不无限驱逐活跃操作；每设备请求2/s burst2、一个在途请求（rate limit为设计） |

v1 `updated_at` 是整数UTC seconds（参考模型0..2100-01-01），协议不发送本地格式化日期。状态支持 DORMANT/IDLE/RUNNING/DONE/FAILED/OFFLINE/WAITING_APPROVAL。当前会话在新page之外也显示于 picker 的独立已选栏，不能通过名称反推选择。

近期索引必须在来源层按 updated_at倒序遍历，并以(agent,id)稳定排序，最多检查200行/一个时间预算。Pi目前无SDK cursor：0.6b首版可缓存后台刷新结果，但要给刷新加目录扫描预算并说明 truncated；较大目录后续做增量索引。参考模型只消费前201个输入以确定 truncated，再排序已收集项，**不保证未扫描历史中的全局最新**；调用方应提供已授权/已预算的近期迭代器。达到cap时显示“More sessions in Admin”，不假装列表完整。

## Target 选择事务

```json
{"v":1,"type":"target_select","request_id":"bootA-3",
 "snapshot":"opaque-snapshot","project":"esp32-dev",
 "agent":"codex","session_id":"session-a17","expected_target_generation":7}

{"v":1,"type":"target_selected","request_id":"bootA-3",
 "target":"agent:codex:session-a17","target_generation":8}
```

1. WS认证绑定 device_id；连接生成 nonce。snapshot属于该 device+connection，不接受客户端提供的其他device身份。registry中的授权项目才进入catalog；展示过并不永久授权。
2. snapshot有效、agent/session属于它、project匹配；当前 target generation 必须与 expected 一致。一次只处理一个选择，重复同 request_id+完全相同 payload 返回原ACK；同ID不同payload拒绝。
3. resolve阶段重新验证当前project授权、目录存在及 canonical cwd、session归属和adapter可用性；这是 await 后必须再次检查的条件。活动 approval/voice/settings_apply/不可中断状态返回 BUSY，不暗中取消正在说的话或批准请求。
4. 每设备串行锁内再次验证connection、generation和busy，持久化canonical target **与generation**，然后发布ACK。事务必须和Admin target变更共用控制器；现有仅写字符串的TargetStore不足以承担这项CAS，需小版本迁移/持久化epoch。避免ABA：target A→B→A也增加generation。
5. ACK后设备再改变active target、清旧picker/settings请求，接受该target的新agent_state。复用现有voice、approval、activity、final_text、model、effort、Speech Mode路径；不启动prompt，也不以选择代替resume。

同一request最多保存60s的ACK，全局64条。late ACK在设备退出页面/换连接后不得重新打开Agent；若ACK的generation落后于已知target_state，则不回滚选择。超时是“结果未知”：显示Reload并查询authoritative target，而不是自动再选一次。Gateway重启后连接nonce使旧catalog失效，重发旧请求必须刷新，而不是盲目重放。

新client取消要清pending request。取消catalog读取可以只丢响应；**已经提交target_select无法保证撤销**，离开页面后仍需在后台对齐authoritative target。参考 `core.target_ack` 没有撤销远端事务能力。这个边界应在integration test中模拟“持久化完成但ACK丢失”。

错误统一 `catalog_error/target_select_error` + request_id + code（INVALID_REQUEST / STALE_CATALOG / TARGET_CHANGED / SESSION_UNAVAILABLE / BUSY / OFFLINE / UNSUPPORTED）+ bounded≤96bytes显示文案；不透传堆栈/路径。部分adapter离线需要显示Partial catalog及对应状态，不把“Pi请求失败”渲染为空列表。参考模型没实现WS/auth/persistence/rate limiter/异步锁/error serializer；这些是0.6b集成任务，不能把host PASS等同端到端可上线。

## Settings 的归属

| 设置 | 所属/持久化权威 | 操作规则 |
|---|---|---|
| Project（picker上下文） | 临时浏览状态 | 不改变target；切project清sessions |
| Session/target | Gateway device-scoped TargetStore | ACK/CAS后生效；切app不切target |
| Model / Effort | session-scoped，adapter/native session | 影响所有观察同session的客户端；忙碌时拒绝；用实际available_models/efforts |
| Speech Mode | Gateway **device+target-scoped** SpeechPreferences | 保留OFF/MANUAL/AUTO_REPLY/AUTO_SUMMARY；不写NVS全局音量字段 |
| Brightness / Volume / Vibration / Home / Text Size / Theme / WatchFace | device-scoped NVS DevicePreferences | 本地可离线改；音量不跟session变化 |

Model catalog延用现有quick_catalog最多6项但仍需实际wire budget检查；不能让remote名称突破新UI的内存预算。Pi当前settings适配只支持model、effort为空，不能为了统一UI虚构Pi的effort档位。Auto read/summary、notification未来再单独定scope，不增加本轮设置项。

## Workspace discovery（0.6c，design-only）

选择已注册Project最先做。添加已存在目录次之；不在手表上输入绝对路径。不把 `POST /api/projects` 原管理权限直接赋给所有设备。

提案 `GET /api/workspaces?root_id=workspaces&cursor=...`，只返回root_id、opaque workspace_id、短名称、registered/project_id；不返回任意目录内容。allowed roots由管理员配置，并预先以realpath验证，例如专用 `/home/lighthouse/workspaces`。`/home/lighthouse` 仅是用户给出的候选，**不建议默认启用**，会把`.ssh/.codex/.config`等敏感目录纳入过宽可见范围。

安全边界：

- roots是服务端allowlist和稳定root_id；请求不得提交path、`..`、绝对路径、斜杠、反斜杠、NUL或编码变体。候选token→服务器已验证的目录；不是客户端字符串拼接。
- 默认只看root下一层，max_depth=1（管理员最多2）；一次scan≤128 entries/100ms、page≤4、candidate snapshot≤64，返回truncated而非继续递归。排除点目录、`.git`、缓存、特殊文件和网络挂载（默认不跨设备）。
- 使用dirfd为root锚点、逐段`openat(..., O_DIRECTORY|O_NOFOLLOW)`（Linux可用openat2 RESOLVE_BENEATH/NO_SYMLINKS）；所有symlink直接不展示。只做`resolve().is_relative_to()`后再按原字符串open会有TOCTOU漏洞，不能作为最终实现。
- 注册前再验证root身份、device授权、目录inode/权限/不跨mount；使用打开的descriptor作比较，不信任旧snapshot。服务以低权限身份运行。返回错误不泄露allowlist之外的路径。
- roots发生变化立即失效旧cursor；scan并发限制1/device，identity token有TTL。选择不会默认执行目录里的脚本、AGENTS、Git hook或初始化agent runtime。

测试矩阵：`..`和URL编码、绝对路径、symlink越界/环、root换inode、检查后替换目录、权限拒绝、超深/超多entries、同名目录、Unicode短名、消失的目录、过期token、未授权device、分页无重复。仅允许tmpdir fixture，不能在真实用户home跑mutation测试。

## + New Workspace（最后做，design-only）

价值是离开Web/Admin也能开一个干净项目；手表命名输入成本高，先不做路径/软键盘。建议选root后显示服务端分配的 `project-001` 或日期+短随机后缀并二次确认；可选友好名称在Web再改。

若未来实现：服务端生成安全basename；dirfd锚定root，atomic `mkdirat` + restrictive0750，不follow symlink，不覆盖已存在内容；碰撞用有限次数重试和唯一idempotency key，权限继承经policy明确。默认创建**空目录、不git init**；Git是单独选项并禁止运行hook/继承不可信template。目录创建与registry不是同一事务，需operation journal：崩溃后重放注册，不重复创建。失败只能回收本次新建且inode仍匹配且仍为空的目录，不能递归删除。只有这些隔离测试完成后才开放设备调用。

New Workspace与New Session是两种动作：创建目录不自动运行Agent；创建session必须绑定已注册、授权、canonical cwd的Project。两者都不属于0.6a/0.6b。
