magic-agent 2.6.0 → 2.6.3

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.
Files changed (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -12,7 +12,7 @@
12
12
  | 面 | 命令 / 通道 | 状态 | 实现 |
13
13
  | --- | --- | --- | --- |
14
14
  | 引擎调用 | `-e <engine> -p "<prompt>"`(含超时、重试、退出码) | 稳定 | `internal/cli/ask.go` |
15
- | 探测与能力 | `--engines [--no-models]`:`ok` / `bin` / `install` / `version` / `models` / `model_credits` / `credit_left` / `credit_suffix` / `model_quota` / `checkin_today` / `checkin_credit` / `permission` / `reasoning` / `capabilities` …(`credit_left` 为账号剩余额度,`credit_suffix` 为它的单位:codebuddy 系省略=积分,llm 的 MiniMax 套餐给 `%`;`model_quota` 为**按模型**的套餐额度快照 `{model: {interval_left, weekly_left}}`,仅 llm 的 MiniMax 模型给出,**按各自 apiKey 查**;`checkin_today`/`checkin_credit` 为每日签到状态,仅 codebuddy 系给出,查不到则省略;`logged_in` 为多账号槽位是否已登录,仅 codebuddy / codebuddy-ai 给出,客户端**只显示已登录的账号行**;`account` 为该槽位登录的账号昵称(票据里的 `account.nickname`,如 `Darren`,客户端画在引擎名下面)) | 稳定 | `internal/cli/root.go` + `internal/agent/credits.go` |
15
+ | 探测与能力 | `--engines [--no-models]`:`ok` / `bin` / `install` / `version` / `models` / `models_note` / `models_source` / `model_credits` / `credit_left` / `credit_suffix` / `model_quota` / `checkin_today` / `checkin_credit` / `permission` / `reasoning` / `capabilities` …(`credit_left` 为账号剩余额度,`credit_suffix` 为它的单位:codebuddy 系省略=积分,llm 的 MiniMax 套餐给 `%`;`model_quota` 为**按模型**的套餐额度快照 `{model: {interval_left, weekly_left}}`,仅 llm 的 MiniMax 模型给出,**按各自 apiKey 查**;`checkin_today`/`checkin_credit` 为每日签到状态,仅 codebuddy 系给出,查不到则省略;`logged_in` 为多账号槽位是否已登录,仅 codebuddy / codebuddy-ai 给出,客户端**只显示已登录的账号行**;`account` 为该槽位登录的账号昵称(票据里的 `account.nickname`,如 `Darren`,客户端画在引擎名下面);**`models_source` 为模型来源语义(2026-10 只增字段,不升 contractVersion):`dynamic` = models 可用 / `none` = 引擎本身不提供模型选择(arkclaw 按 claw_id 绑模型、codebuddy-gateway 用网关进程的模型),**不是故障,客户端必须保留该引擎行**并显示「引擎默认」/ `failed` = 有来源但本次探测失败(如 openclaw CLI 非 0 退出),可隐藏或置灰;老 core 无此字段时客户端按 `models_note` 的 `no dynamic model source` 前缀兜底**) | 稳定 | `internal/cli/root.go` + `internal/agent/credits.go` + `internal/agent/models.go` |
16
16
  | 额度 / 签到现查 | `--quotas`:只查额度与签到,**不探测引擎、不启动任何 CLI**(`{engine, credit_left?, credit_suffix?, model_quota?, checkin_today?, checkin_credit?, logged_in?, account?}[]`,字段与 `--engines` 同名同义;秒级)。客户端用它把最新额度合并进缓存的引擎清单 —— 模型清单可缓存,额度不能 | 稳定 | `internal/cli/quotas.go` |
17
17
  | 每日积分 | `--checkin <engine>`:领取该引擎账号的每日积分(codebuddy / codebuddy-ai),输出 `{engine, ok, credit, streak_days, checked_today, message?, credit_left?}`;业务失败(今天已领)也是 exit 0 + `ok:false` | 稳定 | `internal/cli/checkin.go` |
18
18
  | 契约 envelope | `--contract`:`{contractVersion, version, features, engines[], teamRoles[]}`,`engines[]` 与 `--engines` **同构**;`version` 是 core 自身版本(2026-10-22 只增字段,与 `--version` 同一份,老 core 缺失时客户端显示「—」);`teamRoles[]` 为团队角色清单(2026-10-22 只增字段,字段缺失时客户端回落内置清单) | v2 | `internal/cli/root.go` + `internal/agent/capability.go` + `internal/team/role.go` |
@@ -31,6 +31,7 @@
31
31
  {"type":"tool_result","text":"<output>","id":"toolu_x"}
32
32
  {"type":"turn_end","text":"<该轮正文>","session_id":"…"}
33
33
  {"type":"turn_failed","session_id":"…","error":"<完整错误链>","reason":"<根因>","status":"failed"}
34
+ {"type":"turn_failed","session_id":"…","reason":"429 You have exceeded the 5-hour usage quota. It will reset at …","error":"<同上>","status":"failed","error_kind":"quota","error_code":"429","reset_at":"2026-10-25T15:07:27+08:00"}
34
35
  {"type":"ask","text":"<一行摘要>","ask":{…}}
35
36
  {"type":"result","engine":"…","model":"…","session_id":"…","text":"<全文>","thinking":"…","tools":[…]}
36
37
  {"type":"error","engine":"…","reason":"<根因>","error":"<完整错误链>"}
@@ -102,6 +103,18 @@ core 把它编码成引擎自己的控制应答(codebuddy:`{"allowed":true}`
102
103
  | `snapshot` | 该 item 到此刻的**整段内容**(累积快照,不是增量)——⚠️ **只在实时流里**;落盘不写(见下方「落盘不存 snapshot」) |
103
104
  | `parent` | 父委派 id(2026-10-21 只增字段,不升 `contractVersion`)。空 = 主会话自己的事件。语义与 claude 系原生的 `parent_tool_use_id` **完全一致**:带它的 `text` / `thinking` / `tool_use` / `tool_result` 属于父委派那次调用内部,渲染时挂进父卡的嵌套区而不是另起一笔 |
104
105
  | `error` / `reason` | 仅 `turn_failed`:完整错误链 / 最内层根因 |
106
+ | `error_kind` | 仅 `turn_failed`(2026-10-25 只增字段):失败分类。`quota` = 额度用尽(余额 / 套餐用量上限,等恢复或换模型),`rate_limit` = 频率限制(隔一会儿再试)。**空 = 分不出来**,客户端退回普通失败卡 —— 别硬套标签 |
107
+ | `error_code` | 仅 `turn_failed`:通告里的状态码(`429` / `402` / `401` / `403` / `5xx`)。空 = 原文里没有 |
108
+ | `reset_at` | 仅 `turn_failed`:供应商给的**恢复时刻**,RFC3339(如 `2026-10-25T15:07:27+08:00`)。空 = 没说什么恢复 —— 客户端此时**不要**编「稍后」,把那一行整条省掉 |
109
+
110
+ > **为什么这三项在 core 而不在客户端**:分类与时刻解析的口径只有一份(`internal/agent/limitnotice.go`)。
111
+ > 客户端各自写正则必然各错一处,而且「把工具报错画成额度不足」比不画更糟。
112
+ > 客户端只做展示:`error_kind` 命中就画额度卡(标题 / 徽章 / 恢复时刻 / 建议 / 可展开原文),
113
+ > 其它一律退回普通「调用失败」卡。
114
+ >
115
+ > ⚠️ 额度那一类还有**正文侧**的约定:core 剥掉通告后的正文放在 `turn_failed.text`;
116
+ > 当这一轮**只有通告、没有真正文**时它是**空串**,而流式期间那段通告早就拼进气泡了 ——
117
+ > 客户端要按 `error_kind` 非空 + `text` 为空去**撤掉已画的正文**,否则卡片下面会一直铺着原文。
105
118
 
106
119
  ⚠️ **`parent` 不是团队模式专用**(这一点很关键,别把它设计成开关)。claude / codebuddy 原生
107
120
  就会吐 `parent_tool_use_id`(子 agent 调用),core 只是把它**统一**成一个字段;
@@ -423,8 +436,8 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
423
436
  | 模块清单 | `GET /desk/modules`(从 profile 派生:id / 入口 / 声明的表) | 已实现 |
424
437
  | 会话登记表 | `GET /desk/sessions`(core 的 `--sessions`,跨进程持久;**只回本客户端发起的会话**) | 已实现 |
425
438
  | 项目业务接口 | `ANY /api/*` → 原样转发到 profile 的 `backend.url` | 已实现 |
426
- | 发起一轮 | `POST /desk/ask`(SSE 中继 core 的 NDJSON,前置 `event: open`,收尾 `event: end`)。**服务端会先入队**:同 workspace 串行 + `request_id` 幂等;真在等时先推 `event: queued`(`{job, state, position}`,客户端可忽略),准入后照旧 `open` | 已实现 |
427
- | 作业队列(多客户端) | `POST /desk/jobs`:提交(不挂长连接,2xx 即可);`GET /desk/jobs[?workspace=][&limit=]`:队列快照(含他人任务与 `position`);`GET /desk/jobs/{id}`;`GET /desk/jobs/{id}/events?after=<seq>`:按 seq 回放本轮事件(形状同 `ask/{id}/events` + `job` 字段);`POST /desk/jobs/{id}/cancel`;`POST /desk/jobs/cancel {request_id}`(还没拿到 job id 时取消);`POST /desk/jobs/{id}/update {serial}` 与 `POST /desk/jobs/update {request_id, serial}`(2026-10-24,只增:改**排队中**作业的串行/并行 —— 客户端切任务模式时同步旧排队条目,串行↔并行马上生效;已开跑的作业回 `updated:false` 不打断);`GET /desk/jobs/events?[workspace=]`:队列变更 SSE(每 2s 一帧 `event: jobs`)。持久化在 `<profile data dir>/jobs/`,服务重启后 queued 保留、running 残留标失败。作业记录里 `fallback_engine` / `fallback_model` 是**配置**;`fallback_used` / `fallback_engine_used` / `fallback_model_used`(2026-10-26 只增)是**本轮真的由备用接上了**(服务端扫 `type:"fallback"` 帧回填),没降级就不出现 —— 排查「主备生效没有」看这三个 | 已实现 |
439
+ | 发起一轮 | `POST /desk/ask`(SSE 中继 core 的 NDJSON,前置 `event: open`,收尾 `event: end`)。**服务端会先入队**:同 workspace 串行 + `request_id` 幂等;真在等时先推 `event: queued`(`{job, state, position}`,2026-10-26 起另带 `serial` 与 `auto`,客户端可忽略),准入后照旧 `open` | 已实现 |
440
+ | 作业队列(多客户端) | `POST /desk/jobs`:提交(不挂长连接,2xx 即可);`GET /desk/jobs[?workspace=][&limit=]`:队列快照(含他人任务与 `position`);`GET /desk/jobs/{id}`;`GET /desk/jobs/{id}/events?after=<seq>`:按 seq 回放本轮事件(形状同 `ask/{id}/events` + `job` 字段);`POST /desk/jobs/{id}/cancel`;`POST /desk/jobs/cancel {request_id}`(还没拿到 job id 时取消);`POST /desk/jobs/{id}/update {serial}` 与 `POST /desk/jobs/update {request_id, serial}`(2026-10-24,只增:改**排队中**作业的串行/并行 —— 客户端切任务模式时同步旧排队条目,串行↔并行马上生效;已开跑的作业回 `updated:false` 不打断);`GET /desk/jobs/events?[workspace=]`:队列变更 SSE(每 2s 一帧 `event: jobs`)。持久化在 `<profile data dir>/jobs/`,服务重启后 queued 保留、running 残留标失败。**自动模式(2026-10-26,只增)**:作业带 `mode:"auto"` 与 `auto` 结论对象 `{conflict, level, reason, overlaps, by, at}`(`by` = `rule` 规则 / `model` 建议引擎 / `undecided` 没判出来按并行 / `pending` 判定中);状态多一个 **`judging`** —— 已受理、正在判定,**不是调度候选**、不会被抢跑,判定完成(异步,客户端提交侧立即返回,不等)后回 `queued` 或直接开跑。客户端遇到 `judging` 应保留「等待中」行原地不动(别当终态删掉);服务重启时残留的 `judging` 直接按**并行**转 `queued`(**不重判**)。判为串行的自动作业会等**同空间所有在跑的作业**(含并行作业)收尾,普通 `serial` 作业的老语义不变。作业记录里 `fallback_engine` / `fallback_model` 是**配置**;`fallback_used` / `fallback_engine_used` / `fallback_model_used`(2026-10-26 只增)是**本轮真的由备用接上了**(服务端扫 `type:"fallback"` 帧回填),没降级就不出现 —— 排查「主备生效没有」看这三个 | 已实现 |
428
441
  | 控制与回审批 | `POST /desk/ask/{id}/control`(与 core 的 `--control` 同协议,插件不解释 op;`ping` / `interrupt` / `stop` / `answer` / `ask_answer`,后两者见 1.1) | 已实现 |
429
442
  | 断线续传 | `GET /desk/ask/{id}/events?after=<seq>`(按**本轮流 seq** 增量补拉;带 `done` / `code` / `stderr` / `snapshotRequired`;`after=0` 时首条是本轮**合成的用户提问**,见 2.4) | 已实现 |
430
443
  | 运行中列表 | `GET /desk/asks`(**本进程**正在跑的轮次,每项 `{id, engine, started, session?, run?}`;与 sessions 的区别见下) | 已实现 |
@@ -443,9 +456,14 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
443
456
  | 脚本解释器与环境变量 | `interpreter`(可空)= 解释器绝对路径,填了就跑 `<解释器> <脚本路径> [参数…]`,不填就直接跑脚本文件本身。**解释器不受上面的扩展名白名单约束**(白名单里没有 `.exe`,而 Python/Node 脚本在 Windows 上没法直接 exec),但**必须是绝对路径、必须是存在的文件、不能是目录**。`env`(可空)= 追加的环境变量数组,逐条 `"KEY=VALUE"`(KEY 不能含空白,条数 ≤ 32),会覆盖同名的父进程环境变量。典型用途:Python 脚本要 `PYTHONUNBUFFERED=1`(否则 stdout 块缓冲,实时日志变成「跑完一大半才刷出来」)+ `PYTHONIOENCODING=utf-8`(否则中文日志乱码) | 已实现 |
444
457
  | 脚本启停 | `POST /desk/scripts/{id}/start` → 200(条目 + 运行态,**不挂长连接**);`POST /desk/scripts/{id}/stop` → 200。启动 = spawn 一个**独立进程组**的后台进程(停止时整组杀,不留孤儿 worker);状态机 `stopped → running → (done \| failed \| stopped)`。运行中重复 start → 409。**用户主动停的记 `stopped`**(不是 `failed`) | 已实现 |
445
458
  | 脚本日志 | `GET /desk/scripts/{id}/log?after=<seq>` → SSE:`event: line`(`{seq, text}`)逐行 → `event: state`(运行态)收尾。`after=<seq>` 只补之后的行(断线重连用;**序号跨重启单调递增**,否则重连会一行都收不到)。服务端内存留最近 500 行(环),完整副本落 `~/.magic-agent/scripts/logs/<id>.log`(**追加**)。脚本在**这台桌面**上跑,不是手机 | 已实现 |
446
- | 移动端 APK 分发 | `GET /desk/app` → `{version, size, url}`(`version` 取自随包的 `npm/apk/version.json`,与 npm 包同一个版本号;没带 APK 时 `version` 为空串、`url` 为空);`GET /desk/app.apk[?token=]` → 安装包本体(`application/vnd.android.package-archive`)。移动端「我的」→「版本」点一下先读前者,发现更高版本后由系统浏览器/安装器下载后者 | 已实现 |
459
+ | 移动端 APK 分发 | `GET /desk/app` → `{version, size, url, upgrade}`(`version` 取自随包的 `npm/apk/version.json`,与 npm 包同一个版本号;没带 APK 时 `version` 为空串、`url` 为空);`upgrade`(2026-10-26 只增字段,不升 `contractVersion`)= **这台桌面能不能让手机顺手升级 npm 那份 core**(npm 在 PATH 上且找得到目标二进制),老插件没有该字段 → 按 `false` 处理;`GET /desk/app.apk[?token=]` → 安装包本体(`application/vnd.android.package-archive`)。移动端「我的」→「版本」点一下把 `/desk/app` 与 `/desk/core` 一起读,两条链有更新就一起摆出来 | 已实现 |
460
+ | 桌面端 core 版本 / npm 升级(2026-10-26) | `GET /desk/core` → `{version, installed, latest, upgradable, restartOnly, canUpgrade, upgrade, bin, prefix, daemon, note}`:`version` = **此刻正在跑的那份** core 版本(盘上二进制若在本进程启动后被换掉,则以契约缓存为准 —— 读新文件会答出没在跑的版本);`installed` = 盘上那份的 `--version`;`latest` = `npm view magic-agent version`(离线为空串);`upgradable` = 有更高版本可装;`restartOnly` = 已经装好、只差重启(`installed > version`,或二进制是本进程启动后才换上去的);`canUpgrade` = 本机能执行 npm 升级;`upgrade` = 将要执行的完整命令(只读展示用);`bin`/`prefix` = 目标二进制与 npm 前缀。**`/desk/core` 永不触发安装**。
461
+ `POST /desk/core/upgrade` → SSE:`event: open`(`{command, bin, from, to}`)→ `event: step`(`{index, command}`)→ 输出逐行 `event: line`(`{index, line}`)→ `event: end`(`{ok, code, restarting, note?, error?, core}`,`core` 同 `GET /desk/core` 的形状,前端据此刷新版本格)。执行 `npm install magic-agent@<latest> --no-fund --no-audit -g [--prefix <prefix>]`,**绝不降级**(`latest` 不高于已装 → 409)、**盘上已是本进程启动后换上的新版 → 409「只差重启」,不再装一遍**;装完以「盘上那份的版本真的变了」为成功判据(npm 退出 0 但没装成 → `ok:false` + 原因),成功后若本实例是后台 `magic-client start` 拉起的,走与 `POST /desk/restart` 同一条原地重启链(`restarting:true`),否则提示手工重启 | 已实现 |
447
462
  | 移动端环境配置 | `GET /desk/prefs` → 这台桌面的移动端配置 JSON(没存过是 `{}`);`PUT /desk/prefs` 整份覆盖(≤256KB,原子写)。字段由移动端定义、桌面端原样存(`~/.magic-agent/mobile-prefs.json`),当前是「默认引擎/模型 + 团队配置 + 空间选择 + 置顶」。**多客户端连同一台桌面共用这一份** —— 不再是各存各的浏览器 localStorage | 已实现 |
448
- | 重启后台服务 | `POST /desk/restart` → `{ok:true, restarting:true, pid}`。桌面端**先回响应**,再由一个脱离终端的 `magic-client restart --delay 1s` 助手停旧实例、按 `client.json` 里的参数拉起新实例(端口 / 令牌 / 界面目录 / profile 沿用,手机里的地址不用改)。⚠️ 会话是它起的子进程 → 正在跑的会话会被中断(状态落 `interrupted`/`gone`)。只有**后台实例**(`magic-client start` 起的)能自重启:状态文件缺失或 `pid` 不是本进程时回 409,前台 `serve` 请到桌面执行 `magic-client restart` | 已实现 |
463
+ | 飞书推送 · 群机器人(2026-10-26) | `POST /desk/feishu/webhook {webhook, webhook_secret?, url?}` —— **推荐通道**:地址必须形如 `https://open.feishu.cn/open-apis/bot/v2/hook/<token>`(`open.larksuite.com` 也认),**落盘前当场发一条测试卡片**,发不出去就不保存。`webhook_secret` 非空时按飞书自定义机器人规范签名(以 `timestamp + "\n" + secret` 为密钥对空串做 HMAC-SHA256 再 base64)。配了它就优先走它,应用凭据与收件人都不再需要 | 已实现 |
464
+ | 飞书推送 · 绑定会话(2026-10-26 定稿) | `POST /desk/feishu/bind {code, to}`:**移动端只让用户填一个 `oc_` 开头的会话 ID**,绑定码由页面透明地先调 `POST /desk/feishu/code` 取到再一起提交(用户看不见)。服务端据此写 `receive_id/receive_type=chat_id`,推送走 `receive_id_type=chat_id`。绑定成功顺手发一条确认卡片;状态里 `bound_to` 只回末 6 位(`…8ce2a4`) | 已实现 |
465
+ | 飞书推送(2026-10-26) | `GET /desk/feishu` → `{mode, configured, source, app_id, bound, enabled, bound_at?, bound_type?, bound_to?, webhook_tail?, open_id_tail?, recent[]}`(`mode` = `webhook` / `app`;`bound_to` 是**打过码**的收件人)(**永不回显 app_secret**,open_id 只给末 6 位);`POST /desk/feishu/code` → `{code, expires_in}`(6 位数字,10 分钟有效);`POST /desk/feishu/bind {code, to}` 绑定并自动开启(码一次性、错码/过期拒绝)。`to` 可以是**飞书邮箱**(`receive_id_type=email`,推荐)、`ou_` 开头的 open_id(须 ≥20 字符,`ou_me` 这类占位符直接 400)、或 `oc_` 开头的群 id;老的 `{open_id}` 字段仍接受;`POST /desk/feishu/unbind`;`POST /desk/feishu/enable {enabled}`;`POST /desk/feishu/config {app_id, app_secret}`(先验令牌再落盘,`~/.magic-agent/feishu-notify.json` 0600);`POST /desk/feishu/test` 发测试卡片;`POST /desk/feishu/push {event: result\|confirm, ask_id, session_id?, title?, body?}` 幂等推送(键 = event+ask_id,重复回 `{ok,dupe}`;没开/没配回 `200 {disabled,reason}`;发送失败退避重试 3 次后 502 并释放幂等键)。凭据来源优先级:本机文件 > `MAGIC_AGENT_FEISHU_APP_ID/SECRET` > config.json 的 `feishu` 节。手机端在 `pollOnce` 的 running→done / waiting_approval 跳变时调用 push(`status_ask_id` 为幂等键) | 已实现 |
466
+ | 重启后台服务 | `POST /desk/restart` → `{ok:true, restarting:true, pid}`。桌面端**先回响应**,再由定时器(默认 1s)拉一个脱离终端的 `magic-client restart --state <本实例的状态文件>` 助手停旧实例、按那份状态里的参数拉起新实例(端口 / 令牌 / 界面目录 / profile / `--lan` / `--allow-any-origin` / `hostPid` 沿用;公网域名本就由壳托管、不随客户端重启变化,见 `/desk/health.publicUrl` 的落盘说明)。⚠️ 会话是**这个实例**起的子进程 → 它起的会话会被中断(状态落 `interrupted`/`gone`);**同机其它客户端实例的会话不受影响**(`gone` 的判据是那条会话自己的 pid 还在不在,见 `internal/session/session.go`)。**两条启动路径都能自重启**(2026-10-26):`magic-client start` 起的认 `client.json`,前台 `serve` 认 `<数据目录>/serve.json`(serve 监听成功后自登记,`--state` 可覆盖)。一条状态记录都没有(老版本 serve / 显式跳过登记)时回 **409**,`error` 里直接给出可照做的命令。重复点击幂等(`already_pending:true`),不会再起第二个助手 | 已实现 |
449
467
  | 内置浏览器中转 | `GET/POST /b/<scheme>/<host><path>[?magicToken=]`:服务端代取页面并就地改写(链接/资源指回中转、剥 `X-Frame-Options`/CSP、注入 `<base>` 与 relay 脚本),iframe 的流量走**桌面端网络**。鉴权自管(不套 guard):**改写出的每条 URL 都带 `?magicToken=`**(转发上游前由服务端剥掉,不泄漏给目标站);另在首帧下发 cookie `magic_browse` 作辅助凭据 —— 跨站 iframe(APK 壳 origin 与桌面端 LAN 不同源)下 SameSite=Lax cookie 不随子资源请求发送,不能只靠它。`GET /b/relay.js` 是注入脚本本体(见下一行)。⚠️ 移动端 UI **默认直连**(2026-10-25 起):不开浏览器里的「桌面端中转」开关时 iframe 直接加载目标站、不走本中转;开关打开后才走 `/b/`(桌面独有站点 / 拒绝内嵌的站点 / 需要 relay 注入时用) | 已实现 |
450
468
  | 浏览器输入中继 | `WS /browse-input?role=viewer\|driver[&token=]`(鉴权同 `/b/`;`role` 缺省按 driver)。页面内 relay 脚本连 `viewer`(同一时刻至多一个,后连顶前连),手机 UI 连 `driver`(可多个)。JSON 文本帧:viewer→server 发 `hello`/`state`(`{title,url,w,h}`,页面 CSS 像素)/`ack`(`{seq,ok,reason?}`);server→viewer 发 `input`(`{seq,action,x?,y?,dx?,dy?,text?,key?}`,`action` ∈ `move\|down\|up\|click\|dblclick\|scroll\|type\|key`)与 `ping`;driver→server 发 `input`(server 重编 seq 后转发,无 viewer 时回 `ack{ok:false}`)与 `ping`(转发给 viewer 请求一帧 `state`,无 viewer 回 `ack{ok:false}`);server→driver 发 `state`/`ack`/`viewer`(`{online}` 上下线通知)。坐标 = 被代理页面自身 CSS 像素。**2026-10-25 起 relay 只承担「远程触控」**:`shot` 动作已退役(页面内 html2canvas 有跨域污染 / iframe 抓不到 / 慢页超时三个硬伤),截图与程序化驱动走 agent-browser(见下一行)。已知边界:移动端 UI 默认直连(不走 `/b/`),只有经中转加载的页面才注入 relay 脚本、中继才可用;合成事件 `isTrusted=false`,个别反自动化站点会忽略 | 已实现 |
451
469
  | 浏览器 MCP(agent-browser,2026-10-25) | 驱动/截图面基于 **agent-browser 插件**(vercel-labs,Rust 原生 CLI,真实 Chromium/CDP,默认 headless,daemon 模型:首次 `open` 自动拉起、闲置约 1h 自动退出)。CLI:`magic-client browse open <url> \| state \| click <x> <y> \| move <x> <y> \| dblclick <x> <y> \| scroll <dx> <dy> \| type <文本> \| key <键名> \| shot [--out 文件] [--full-page] \| eval <js> \| pdf <文件> \| snap [-i]`。会话固定 `--session magic-client`(与人类/其它工具的 default 会话隔离,登录态可跨动作复用)。探测链:环境变量 `MAGIC_AGENT_AGENT_BROWSER_BIN` > npm 全局前缀(`<prefix>/node_modules/agent-browser/bin/agent-browser-<goos>-<goarch>[.exe]`,**绝对路径 spawn 原生 exe,零 node 依赖**)> PATH;都没有时 CLI 退出码 1 + 安装提示、HTTP 端点回 **501 + 中文原因**(`npm install -g agent-browser && agent-browser install`)。单次调用统一 `--json`(`{"success":true,"data":…}`),超时 60s。⚠️ 与 `/b/` 中转是**两个独立浏览器上下文**:手机 iframe 看的页面走中转 + relay 远程触控;agent-browser 截的是它在服务端自己打开的页面(独立 profile,不共享中转 cookie jar,需登录的页面截出来可能是登录页) | 已实现 |
@@ -453,6 +471,9 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
453
471
  | 需求截图写入 | `POST /desk/requirements/shots?ws=&module=&name=&caption=`(body = **PNG 原始字节**,不是 multipart)。能力位 `feature.requirement.shots` 缺失回 501;`name` 必填;空 body 回 400;超 8MB 回 **413**。core 落盘并把一行追加进该功能点文档的「页面完成情况」段(见 §3.4.1.1) | 已实现 |
454
472
  | 需求关系图谱 | `GET /desk/requirements/graph?ws=<ws_id\|目录>`(留空 = 全部)。能力位同 `feature.requirements`(缺了回 501)。core `--requirements-graph` 纯读转发 → `{ok, root, workspaces:[{id, name, dir?, nodes, edges}]}`(形状见 §3.4.1「会话↔需求关系」) | 已实现 |
455
473
  | 静态资源 | `GET /`(`--ui-dir` + profile 的 `ui.entry`) | 已实现 |
474
+ | 应用(工作台)目录 | `GET /desk/apps` → `{apps:[{id, name, tagline, description?, homepage?, cli, command, port, installed, sourcePath?, note?, openUrl?, state:{state, running, managed, pid?, port?, url, localUrl?, logTail?, startedAt?, error?}}], notes:[…]}`。**这不是引擎,也不是插件清单**:它是「发现 → 应用」里 magic-shorts / magic-video 的**本地工作台**(`magic-shorts serve` → :8899、`magic-video workbench` → :7789)。服务端负责在桌面端起停与反代,客户端只显示与触发。`state.state` = `stopped` / `starting` / `running`;`running` 以**端口探活**为准(别人在终端 / 菜单栏起的也算 running,此时 `managed:false`,手机只能打开、不能停);`url` 是手机可用的相对入口 `/workbench/{id}/`(= `openUrl`),客户端拿它丢给内置浏览器 | 已实现 |
475
+ | 应用启停 | `POST /desk/apps/{id}/{start\|stop}` → SSE:`event: open {id, action}` … `event: end {ok, code, error?, app}`。`start` 起进程后**等端口应答**(最多 20 s)再收尾,起不来把输出尾巴放在 `error` 里(`ok:false`);`stop` 只停自己起的(整组带走),外部实例回 409 + 人话;未知 id / 动作一律 404。命令只来自服务端固定表,客户端不传命令行 | 已实现 |
476
+ | 应用反代 | `GET /workbench/{id}/…` → `127.0.0.1:<port>/…`。**入口** `/workbench/{id}/` 先探活:没在跑回 503 + 人话,在跑则 302 到 `/?via={id}`;页面从此**在根路径下**跑,页面里 `/api/…` 这类根相对请求由 `handleStatic` 按 `Referer` 的 `/workbench/{id}/` 前缀(或 `?via=`)转给工作台 —— 所以移动端内置浏览器的 iframe **不能**是 `referrerpolicy="no-referrer"` | 已实现 |
456
477
  | 鉴权 | `X-Magic-Token` 头或 `?token=`;默认绑 `0.0.0.0:9100` 且令牌强制 ≥24 字符;`--local` 时只绑 `127.0.0.1`(见 2.4) | 已实现 |
457
478
 
458
479
  ### 2.1 扩展接口(业务项目对着写)
@@ -542,6 +563,16 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
542
563
  | `team` | 可选,团队模式(见下)。**nil / 缺省 = 关闭**(默认关)|
543
564
  | `request_id` | 可选(2026-10-23,只增字段),客户端生成的**幂等键**。同一个 id 重复提交(断线重连 / App 重启 / 两个标签页)只执行一次:服务端返回原作业并接回去。空 = 旧客户端,仍参与 workspace 串行,但没有幂等保护 |
544
565
  | `serial` | 可选(2026-10-23,只增字段),默认 `true`:参与「同一 workspace 单槽」串行。客户端「并行模式」显式传 `false`(服务端仍套全局并发上限,`MAGIC_CLIENT_MAX_RUNNING`,默认 4)。**并行作业不注入 core 工作空间锁**(2026-10-24 起,锁只对串行注入)—— 否则同空间「并行」会在 core Begin 处被锁重新排成队;排队中的作业可用 `POST /desk/jobs/update` 改这个标志,模式切换即时生效 |
566
+ | `mode` | 可选(2026-10-26,只增字段),三档:`"auto"` = **自动任务模式**:服务端按「本轮提示词 + 同空间运行中任务的会话摘要/已改文件」判断两边是否冲突,冲突则排进同空间单槽、不冲突照常并行;`"parallel"` = 一律并行、**不判定**;`"serial"` = 同空间单槽串行、**不判定**。**两字段同时给时以 `mode` 为准**,`serial` 只当缺省回退(只发 `mode:"parallel"`、不发 `serial` 也照样并行)。空 / 不认识的值 = 沿用 `serial` 的老语义,老客户端行为不变。判定链路见下 |
567
+ | `judgeEngine` / `judgeModel` | 可选(2026-10-26,只增),`mode:"auto"` 时用哪个引擎做语义判定(客户端传用户配的「建议引擎」)。空 = core 默认 `llm`。判定是旁路调用(新进程、ToolsOff、不写会话历史),判定是异步旁路,**任何失败/超时/未配置都按并行放行** |
568
+
569
+ **自动任务模式(`mode:"auto"`)的判定口径与边界**(2026-10-26):
570
+
571
+ - 判定对象只有**同一 workspace** 的 `running` / `waiting_approval` 会话,外加同空间**还在排队**的作业(提示词即意图)。`idle` 不算 —— 常驻会话本轮已结束,不占文件。
572
+ - 先规则、后模型:① 同一条会话还有一轮没结束 → 直接串行;② 同空间没有任何候选、锁也没被活进程持有 → 直接并行(不调模型,绝大多数提交走这条);③ 提示词里显式写出的路径命中候选**已改文件** → 直接串行;④ 其余交给建议引擎(`judgeEngine`,2.5s 预算)判「两件事是否在动同一个东西」。
573
+ - 失败方向:引擎没配 / 超时 / 输出解析不了 / 并发闸满 → **一律并行**并标 `by:"undecided"`。判定是保护不是闸门,绝不能把任务卡死。
574
+ - 不重判:判为串行的作业在 `queued` 里等候,由调度器在同空间空闲时自然放行;**不会**过一会儿再问一次模型。客户端切回「并行 / 串行」时对已排队作业仍走 `/desk/jobs/update`(即时生效),切到「自动」**不回溯**。
575
+ - **看不见的冲突(如实说明)**:用户在终端里跑的构建 / dev server / 编辑器、以及不经过本插件的进程,没有会话记录也没有工作空间锁,自动模式无从判断 —— 这类并行只能靠用户切「串行」兜底。
545
576
 
546
577
  `team`(2026-10-21 只增字段,不升 `contractVersion`):
547
578
 
@@ -603,6 +634,48 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
603
634
  `restarting` 为假(缺失)的 200 = 新一代已起来。老插件没有该字段 → 客户端照旧
604
635
  「先看到连不上、再看到 200」的判据,两种字段都要认。
605
636
 
637
+ `/desk/health.restartHandoff`(2026-10-25 只增字段,不升 `contractVersion`):旧实例受理重启、
638
+ 且在定时器到点前算出了「新实例会是什么样」时给出
639
+ `{urls: ["https://xxx.trycloudflare.com/", "http://10.0.0.5:9100/"], token: "…"}`;
640
+ 不该交代时是 `null`(不是缺失 —— `/desk/health` 的键集恒定,客户端两种都认)。
641
+
642
+ 为什么需要(用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」):手机存的是
643
+ 「地址 + 令牌」。令牌跨重启稳定(`token` 文件),地址却可能变 —— 换 Wi-Fi 后网卡 IP 变了、
644
+ 端口撞了换了端口、状态文件丢了令牌重发。旧实例恰好能在死前算出新实例的参数,所以把结论
645
+ 挂在**自己**的 health 上,手机探活时顺手取走:`urls` 里**公网域名排第一**(隧道由壳托管、
646
+ 客户端重启不碰它,是重启后最不容易失效的入口),随后才是局域网地址(回环地址不放)。
647
+ `token` 是本机局域网门锁,只回给已持令牌的 `/desk/health`(guard 之后),不裸奔。
648
+ 交接有效期 2 分钟(`restartHandoffTTL`):旧实例没被停掉也不会一直发一份可能过时的地址。
649
+
650
+ `/desk/health.pid` 与 `/desk/health.startedAt`(2026-10-27 只增字段,不升 `contractVersion`):
651
+ `pid` = 本进程 pid(整数),`startedAt` = 本进程启动时刻(RFC3339,UTC)。
652
+
653
+ 为什么需要(用户:「点了手机端重启 好像没效果」):重启换的是**进程**,而会话、任务、
654
+ 设置全在磁盘上 —— 界面重启前后长得**一模一样**。客户端探到 200 就弹「桌面端已重启」,
655
+ 用户看着没变的列表只会得出「没效果」。`instanceId` 帮不上忙:它是**同一台桌面**的稳定
656
+ 身份(多入口归一依赖它),跨重启必须不变,所以不能用来判断新旧代。
657
+ → 客户端应在 `POST /desk/restart` **之前**读一次 health 记下 `pid` / `startedAt`,
658
+ 重启后对比:pid 或 startedAt 变了才是「换了一代」的硬证据,可据此说「已重启(pid A → B)」;
659
+ 两个字段都缺失(老插件)时**不许编造**已换代的结论,如实说「请求已完成、无法核对」。
660
+
661
+ ⚠️ 客户端实现要点(2026-10-27 真机踩过):指纹必须在 POST **之前**读 —— 重启只需 3~7 秒,
662
+ POST 之后再读旧实例可能已经没了,拿不到基线就永远判不出换代。
663
+ 另外重启太快时可能错过「连不上」的空窗,所以不能只靠 `sawDown`;
664
+ **只要探到的 pid 与基线不同,同样算成功**(新进程在应答就是新进程)。
665
+
666
+ 客户端口径:`waitRestartBack` 每次探活都记下 `restartHandoff`,等不到新一代时用它改
667
+ **原环境**的 url / token(eid、名称不动 —— 排队任务与配置槽跟着 eid 走),改完整页重载;
668
+ 改之前一律先验证新入口能连通(`/desk/health` 200)。老插件没有该字段 → 客户端退回
669
+ 「先看到连不上、再看到 200」的旧判据,行为不变。
670
+
671
+ `/desk/health.publicUrl`(2026-10-22 只增字段,不升 `contractVersion`)**现在会落盘**
672
+ (`<profile 数据目录>/public-url`,2026-10-25):公网域名只随**隧道**重起而变,
673
+ magic-client 重启并不碰 cloudflared,可探测结果以前只在内存里 —— 客户端一重启
674
+ `publicUrl` 就空,手机于是退回局域网地址,表现成「重启后地址变了」。现在启动即读回
675
+ 上次的域名顶上、后台探测(`publicTTL` 2 分钟)再纠正:探到新域名就覆盖,连续两次探空
676
+ 且过了 90 秒宽限期(`publicGrace`,cloudflared 可能起得比客户端慢)才认定隧道真没了并
677
+ 删掉落盘值。客户端因此**不该**因为「重启后 publicUrl 暂时没变 / 还是老值」就判它失效。
678
+
606
679
  ⚠️ **`/desk/health.version` 是插件启动时缓存的 core 版本**:只升级 CLI、没重启插件时它会
607
680
  一直报旧值(2026-10-22 实测:CLI 已 2.1.3,手机仍显示 1.0.111)。要显示「此刻真正的
608
681
  core 版本」必须读 `/desk/contract.version`(每次都重新问 core);`health.version` 只适合
@@ -615,6 +688,17 @@ core 版本」必须读 `/desk/contract.version`(每次都重新问 core);
615
688
  桌面端版本放进 `title`。点「版本」读 `/desk/app`,发现更高版本后触发 `/desk/app.apk`
616
689
  下载(系统浏览器/安装器接手,不静默安装)。
617
690
 
691
+ 桌面端 CLI 也走同一次「检测更新」(2026-10-26,用户:「版本检测需要同时考虑 apk 和 npm」):
692
+ `/desk/app` 管 APK,`/desk/core` 管 npm 那份,两条链互不替代 ——
693
+ 手机 App 可能是新的而桌面还是旧的,反之亦然。
694
+
695
+ - 界面把两条链的结果一起摆出来(`App vA → vB` / `桌面 vX → vY`),确认后**先升桌面、再下 APK**:
696
+ 打开系统安装器会切走 App,之后就跑不了 SSE 了。
697
+ - `restartOnly` 为真时界面只说「重启 magic-client 后生效」,**不再喊一次升级** —— 再点一次
698
+ 就是 `npm i -g` 降级安装(原地重启那几秒,旧进程读盘读到的是新文件,很容易误判)。
699
+ - 老插件没有 `/desk/core`(404)→ 只走 APK 那条;`/desk/app.upgrade === false` → 本机装不了
700
+ npm 那份,界面把「桌面」一行标成「请手工升级」,不给一个点了会失败的按钮。
701
+
618
702
  关于 `permission` 的两条语义,客户端必须照做:
619
703
 
620
704
  1. **没选就原样不传,不要替 core 补默认值。** core 只在**显式传过** `--permission` 时
@@ -668,18 +752,23 @@ core 的 stdout 事件流里没有 user 事件(它只写进会话日志),
668
752
  ⚠️ 登记表目录(`~/.magic-agent/sessions`)是**全机共享**的:用户手工跑的
669
753
  `magic-agent -p …`、CI、别的 magic-* 应用都在往里写。所以 `/desk/sessions` 只回
670
754
  **本客户端 + 同族 `magic-*` 来源**的行(`internal/client/service.go` 的
671
- `extraSessionClients`),其余滤掉 —— 原口径见用户 2026-10-12「不是 magic-agent 客户端
755
+ `sessionClientVisible`),其余滤掉 —— 原口径见用户 2026-10-12「不是 magic-agent 客户端
672
756
  调用的任务不显示」。
673
757
 
674
758
  - 本插件自己注入的 `appId`:手机 / 桌面从本客户端发起的会话;
675
- - `magic-test`:**掌天瓶桌面壳**发起的会话(它不经本插件,直接 exec core 并注入
676
- `MAGIC_AGENT_CLIENT=magic-test`,见 magic-test 的 `desktop/agent-cli.cjs`)——
677
- 用户 2026-10-15:「通过 magic-test 新建的会话要在移动端 h5 看到,同时 h5 上要能同时
678
- 看到 magic-test 与 magic-agent 两种来源的会话」;
679
- - `magic-agent`:core 自身 / 直连 CLI 的显式标识。
680
-
681
- 代价:名单之外的记录(匿名 `client` = 手工跑 / CI / 旧记录、以及不相干应用)**不出现在
682
- 客户端列表里**(磁盘上仍然留着,登记表**不再按时间清理**,见 3.4 的存档开关)——
759
+ - **同族 `magic-*` 前缀**(2026-10-26 起不再认死名字):掌天瓶桌面壳(它不经本插件,
760
+ 直接 exec core 并注入 `MAGIC_AGENT_CLIENT=magic-test`,见 magic-test 的
761
+ `desktop/agent-cli.cjs`)、core 自身 / 直连 CLI 的显式标识 `magic-agent`、
762
+ 以及将来新增的任何 `magic-xxx` 客户端 —— 用户 2026-10-15:「通过 magic-test 新建的
763
+ 会话要在移动端 h5 看到,同时 h5 上要能同时看到 magic-test 与 magic-agent 两种来源」;
764
+ 2026-10-26:「多个客户端要能拉到同样的会话历史」。
765
+
766
+ ⚠️ 此前放行名单是**钉死的两个字符串** `{magic-test, magic-agent}`,每接一个新客户端
767
+ 都要回来改常量;忘改的表现是**那个客户端静默少一堆会话**,且只有它看不见,极难归因。
768
+ 现在按前缀判定,新增客户端不必再动代码。
769
+
770
+ 代价:名单之外(匿名 `client` = 手工跑 / CI / 旧记录、以及不相干应用)的记录
771
+ **不出现在客户端列表里**(磁盘上仍然留着,登记表**不再按时间清理**,见 3.4 的存档开关)——
683
772
  想看全机视图用 core 的 `magic-agent --sessions`,那条命令**不过滤**。
684
773
 
685
774
  ### 2.2 扩展接口(业务项目对着写)
@@ -781,8 +870,42 @@ APK 侧让传输层通到远程页的办法:`assets/capacitor.config.json` 里
781
870
  ⚠️ **这只解决传输层,不解决注入层** —— 注入(`window.Capacitor` 存不存在)由
782
871
  Capacitor 自己的本地服务器决定,远程页可能压根没被注入。两者要分开看。
783
872
  2026-10-25 起注入层断的那一档由上面的兜底桥在页面侧补齐。
784
- 校验:`npm run check:notify`(纯逻辑)+ 真机验收(移动端设置页的
785
- 「发一条测试通知」与「通道诊断」两个入口)。
873
+
874
+ **系统通知的边界**:轮询(`pollOnce`,5s)只在**页面活着**时跑 —— 锁屏、切后台、
875
+ 离开同一网络就什么都收不到。需要「人不在 App 里也收到」走**飞书推送**(见下面一节)。
876
+ 校验:`npm run check:notify`(纯逻辑)+ 真机验收(移动端「我的 → 通知与连接 → 系统通知」)。
877
+
878
+ #### 移动端「飞书推送」(2026-10-26)
879
+
880
+ 「我的 → 账户 → 飞书推送」独立子页面:绑定 / 解绑 / 总开关 / 应用凭据 / 测试消息 /
881
+ 最近 5 次推送结果。接口形状见第 2 节「飞书推送」那一行,实现在
882
+ `internal/client/feishu.go`(单测 `feishu_test.go`,假飞书,不碰外网)。
883
+
884
+ - **推送源是桌面端**,不是手机:task 完成 / 待确认时,手机在 `pollOnce` 的同一次跳变里
885
+ 顺带 `POST /desk/feishu/push`,桌面端用飞书机器人推到用户本人(`open_id`)。
886
+ - **幂等键在服务端**(`event|ask_id`):多台手机、多个 tab 各自轮询时同一轮只推一次;
887
+ 发送失败会释放键并退避重试 3 次 —— 「失败一次就永远不再推」是不允许的。
888
+ - **凭据**:`app_id` / `app_secret` 属于部署方,**不下发到移动端**;移动端只写
889
+ `~/.magic-agent/feishu-notify.json`(0600),`GET /desk/feishu` 只回「配没配 / 来源」。
890
+ - **绑定**:6 位绑定码(10 分钟有效、一次性、错码不退让)+ `open_id`。远程页里没有
891
+ 飞书登录态,拿不到用户身份;绑定码只发给申请者本人,够用来定位「推给谁」。
892
+ - **两条通道,优先群机器人**(2026-10-26 用户:「为什么需要邮箱啊」):`POST /desk/feishu/webhook`
893
+ 只要一个地址,收件人就是那个群 —— 不用建应用、不用等审批、不需要 open_id / 邮箱。
894
+ 「应用方式」(App ID/Secret + 收件人)作为进阶保留:只有当你想一条条发给个人时才需要。
895
+ - **卡片带「打开 Magic Agent」按钮**:链接必须**先回打 `/desk/health` 验通**才写进卡片
896
+ (候选顺序:手机在 `push`/`test` 里报的 `url` → 缓存里的公网域名 → 本轮 cloudflared
897
+ 的实际域名,全部并发验、1.5s 上限)。**一个都不通就不放链接** —— 宁可没有按钮,也不给人
898
+ 一个打不开的门。2026-10-26 用户实测「地址不对 无法访问」的根因就是:实例里缓存的公网域名
899
+ 属于上一轮隧道(cloudflared 重启即换域名),启动瞬间 `publicURL()` 会先返回旧值。
900
+ 验通的是公网域名时顺手 `applyProbeResult` 纠正缓存。
901
+ ⚠️ 链接里含访问令牌 —— 等同把控制权交给能看见这个会话的人,正文里明写「别转发」。
902
+ - **收件人优先填邮箱**(2026-10-26 实测教训):`open_id` 是**应用维度**的 —— 同一个人
903
+ 在不同飞书应用下 open_id 不同,且飞书客户端任何界面都不显示它。让用户手抄等于让他猜:
904
+ 实测有人填了 `ou_me`,旧代码照收 → 绑成功、推送全失败、界面还显示「已绑定」。
905
+ 现在 `bind` 接受邮箱(`receive_id_type=email`)或 `oc_` 群 id,并对 `ou_` 做长度校验。
906
+ 状态里存 `receive_id`/`receive_type`;老状态文件只有 `open_id` 时按 open_id 回落。
907
+ - 开关关着 / 没绑 / 没配时 `push` 回 `200 {disabled, reason}` —— 这是**正常状态**,
908
+ 不是错误,客户端不该把它当失败重试。
786
909
 
787
910
  > **组件宿主页必须先引**:`shared/vendor/marked.min.js` + `shared/vendor/purify.min.js`
788
911
  > (并设 `window.QA_HLJS_URL` 指向 highlight.js),再引内核分片 `shared/conv/01-dom.js`、
@@ -1271,6 +1394,55 @@ CLI 默认**什么都不落**,因为绝大多数调用(手工跑一轮 / CI
1271
1394
  (2026-10-15:它的会话要能在移动端 H5 上被列出并点开)。`--append` 在没有登记记录时会按约定找 `<dir>/<run_id>.sock`
1272
1395
  (`session.AppendSocketPath`),所以「默认开着常驻、却追加不了」不会发生。
1273
1396
 
1397
+ ### 3.4.0 多客户端仲裁(2026-10-26,默认生效)
1398
+
1399
+ **用户定调:「是 magic-agent 本身要支持多客户端」。**
1400
+
1401
+ 在 core 里仲裁,而不是让每个宿主自己排一遍。理由是能起轮次的地方本来就有很多:
1402
+ 桌面壳直接 exec core、HTTP 插件排自己的队列、手机连同一个插件、还有人手工
1403
+ `magic-agent -p`、CI、别的 `magic-*` 应用。**任何进程内的队列都只管得住自己那一份** ——
1404
+ 少一个宿主漏实现,同一批文件就已经被两个引擎改了。宿主可以自己再排一层(为了显示
1405
+ 排队位次、给别的客户端让路),但那是**视图与礼貌**,不是正确性的来源。
1406
+
1407
+ | 事实 | 说明 |
1408
+ | --- | --- |
1409
+ | 工作空间串行**默认开** | `MAGIC_AGENT_WORKSPACE_LOCK` 不设置 = 启用;`0` / `false` / `off` / `no` = 关闭。⚠️ 2026-10-26 前是「必须显式设为 `1` 才启用」,只有 magic-client 注入 —— 于是桌面壳那条路完全绕过串行(实测:手机在跑时 PC 能在同目录起第二个引擎) |
1410
+ | 关掉的合法场景 | ① 跑批 / 测试;② 团队模式子任务(与主任务同空间,再取锁就是自己锁自己 —— core 内部按 `ParentRunID` 自动跳过);③ **插件的「并行」模式**:`jobs.go` 让同空间多条作业并行时,必须给这些子进程显式传 `=0`,否则 core 在 `Begin` 处重新把它们串起来,界面上就是「切了并行、任务还是一个一个跑」。判据集中在 `service.go::workspaceLockEnv`(串行不传、并行传 `=0`),并有用例钉住 |
1411
+ | 锁的粒度 | **工作空间**(`-w/--workspace`;未指定则用进程 cwd)。同一空间同时只跑一条轮次,不同空间并行 |
1412
+ | 等待上限 | `MAGIC_AGENT_WORKSPACE_LOCK_WAIT`(Go duration,默认 `10m`)。超时报错里**带占用者身份**(pid / 客户端 / 设备 / 已占用时长),而不是只说「超时了」 |
1413
+ | 锁文件 | `<sessions dir>/locks/<sha1(绝对路径)>.lock`,JSON 内容:`{pid, client, source, run_id, session_id, engine, since, workspace}`。⚠️ **旧格式(`pid\n纳秒时间`)照旧认** —— 升级途中有上一代进程留下的锁文件,只认 JSON 会读不出 pid → 判不出死进程 → 那个锁要等 6 小时 stale 才回收 |
1414
+ | 陈旧回收 | 持有进程已不在 → 取锁方回收(`O_CREATE|O_EXCL` 原子创建 + 按 pid 判死)。`LockHolderFor()` 是**只读**的,绝不替别人判死删文件 |
1415
+ | `--sessions` 新增两字段 | `workspace_locked`(这个会话所在目录是否被**别人**占着,排除自己)+ `lock_holder`(占用者身份)。只增字段,不升 `contractVersion`,老客户端忽略即可 |
1416
+ | 为什么要这两个字段 | 等待发生在 `Begin` 里,客户端**发请求那一刻看不到**「我要等」—— 用户看到的只是「点了没反应、转很久」。把判断上移到列表,客户端就能在按下发送之前说「这个目录正被手机端占着,已排队」 |
1417
+
1418
+ #### 跨客户端的重复接管:不静默杀
1419
+
1420
+ 同一 `session_id` 被两端同时续接时,两边的 `sessionWriter` 各算各的 `seq`,
1421
+ 同一份 `<session_id>.jsonl` 里出现成对重号 —— 一端「已完成」、另一端永远等不到终态
1422
+ (2026-10-21 报障的根因)。原先的处理是**无条件** `SIGTERM` 掉旧的那一轮。
1423
+
1424
+ 多客户端之后这变成一把暗刀:用户在手机上跑着,在 PC 上点开同一条接着问(本意是「继续」),
1425
+ 手机端那条就被**静默终止**,界面显示「突然已停止」。
1426
+
1427
+ 现在的口径:
1428
+
1429
+ | 情形 | 行为 |
1430
+ | --- | --- |
1431
+ | 同一个 `client`(含两边都为空 = 都是直接跑 CLI) | **照旧接管**(先 SIGTERM,宽限期不退再 SIGKILL,写 `cancelled`) |
1432
+ | 跨 `client` | **不杀**,只在 stderr 说明「另一端仍在运行,如需继续请先 `--stop` 那一端」。并发由工作空间锁在 `Begin` 阶段挡住 |
1433
+
1434
+ 留前一条的理由:用户开两个终端跑同一会话同样是灾难,`client` 都是空 = 同一来源。
1435
+ 后一条只说「不同客户端不该互相杀」,没说「并发就对了」。
1436
+
1437
+ #### 客户端该怎么做(三条)
1438
+
1439
+ 1. **不要自己实现串行作为正确性保证**。可以是体验(排队提示、位次),但底层必须有 core 的锁。
1440
+ 2. **`--sessions` 的 `workspace_locked` / `lock_holder` 拿来提示**,别自己读 `locks/` 目录 ——
1441
+ 那是内部布局。读侧用 `LockHolderFor()` 同源的字段。
1442
+ 3. **跨端续接要先提示**:看到同会话另一端 `status` 是活的(`IsLiveState`),
1443
+ 应当问用户「那边还在跑,要接管吗」,而不是直接发出去(core 已经不会再替你杀对面,
1444
+ 但两轮并发仍然不是好事)。
1445
+
1274
1446
  ### 3.4.1 需求聚合(`--requirements` / `--consolidate-requirements`,2026-10-22)
1275
1447
 
1276
1448
  **能力定位**:把「会话里聊过的需求」自动归纳成功能点文档。这是从业务项目(掌天瓶 /
@@ -80,6 +80,14 @@
80
80
  --halo-jade:rgba(15,118,110,.07);
81
81
  --halo-amber:rgba(183,121,31,.085);
82
82
  --halo-indigo:rgba(79,70,229,.07);
83
+
84
+ /* 对话内核(shared/conv/conv.css)深色覆盖里那些半透明叠加底走四条「rgb 三通道」令牌,
85
+ 于是「琥珀 14%」「玉绿 20%」这类底色永远跟着本壳的 --amber / --jade 走,而不是在
86
+ conv.css 里再写死一份灰(2026-10-25 深色配色收口)。暗色那一份在 [data-theme="dark"] 里。 */
87
+ --warning-rgb:183,121,31; /* --amber 亮色 */
88
+ --queued-rgb:183,121,31; /* 本壳没有独立排队金:排队态用琥珀 */
89
+ --success-rgb:13,138,106; /* --jade 亮色 */
90
+ --fail-rgb:214,64,42; /* --cinnabar 亮色 */
83
91
  }
84
92
 
85
93
  /* 暗色主题(2026-09-19):覆盖亮色变量,深色背景 + 浅色文字 */
@@ -96,6 +104,14 @@
96
104
  --halo-jade:rgba(52,211,153,.09);
97
105
  --halo-amber:rgba(251,191,36,.10);
98
106
  --halo-indigo:rgba(129,140,248,.09);
107
+ /* 对话内核(shared/conv/conv.css)深色覆盖里那些半透明叠加底走四条「rgb 三通道」令牌,
108
+ 于是「琥珀 14%」「玉绿 20%」这类底色永远跟着本壳的 --amber / --jade 走,而不是在
109
+ conv.css 里再写死一份灰(2026-10-25 深色配色收口)。⚠️ 必须写在这里::where(:root)
110
+ 的优先级是 0,跟着本主题块一起切换。亮色那份在文件顶部的 :root 里。 */
111
+ --warning-rgb:251,191,36; /* --amber 暗色 */
112
+ --queued-rgb:251,191,36; /* 本壳没有独立排队金:排队态用琥珀 */
113
+ --success-rgb:52,211,153; /* --jade 暗色 */
114
+ --fail-rgb:240,105,83; /* --cinnabar 暗色 */
99
115
  /* 层级刻度在暗色下要**整体换一套**(2026-09-28),这是修一个真实缺陷:
100
116
  :root 里的 --shadow-* 是带蓝调(rgba(15,23,42))的,为白底调的。暗色主题过去只在
101
117
  **4 处**内联覆盖成纯黑(#stage / .card / …),其余任何用 var(--shadow-1) 的组件
@@ -1951,6 +1967,32 @@ body::after{content:"";position:fixed;inset:0;z-index:60;pointer-events:none;opa
1951
1967
 
1952
1968
  .qa-errcard .qa-mk-bd{padding-left:2px}
1953
1969
 
1970
+ /* 额度 / 限流提示卡(2026-10-25,见 29-msg-variants 的 qaQuotaCardNode)----------
1971
+ ⚠️ 与 client-ui/shared/conv/conv.css 里同名那一段**必须逐字一致**:PC 工作台只引本文件、
1972
+ 不引 conv.css(两边各存一份是既有事实)。只改了那边不改这边 = PC 上这张卡没样式。 */
1973
+ .qa-errcard.qa-quota{
1974
+ border-color:color-mix(in srgb,var(--amber) 42%,transparent);
1975
+ border-left:3px solid color-mix(in srgb,var(--amber) 70%,transparent);
1976
+ padding-left:10px;
1977
+ background:color-mix(in srgb,var(--amber) 8%,transparent)}
1978
+
1979
+ .qa-quota .qa-ec-ico{color:var(--amber)}
1980
+
1981
+ .qa-quota .qa-ec-tt{color:color-mix(in srgb,var(--amber) 72%,var(--paper))}
1982
+
1983
+ .qa-qc-badge{flex:none;display:inline-flex;align-items:center;height:15px;padding:0 5px;
1984
+ border:1px solid color-mix(in srgb,var(--amber) 42%,transparent);border-radius:var(--r-xs);
1985
+ background:color-mix(in srgb,var(--amber) 12%,transparent);
1986
+ font-family:var(--mono);font-size:9px;letter-spacing:.06em;white-space:nowrap;color:var(--amber)}
1987
+
1988
+ .qa-qc-reset{margin:6px 0 0;font-size:12.5px;line-height:1.6;color:var(--paper-dim)}
1989
+
1990
+ .qa-qc-reset b{font-family:var(--mono);font-size:13px;font-weight:600;color:var(--amber)}
1991
+
1992
+ .qa-qc-hint{margin:4px 0 0;font-size:12px;line-height:1.55;color:var(--paper-dim)}
1993
+
1994
+ [data-theme="dark"] .qa-errcard.qa-quota{background:color-mix(in srgb,var(--amber) 14%,transparent)}
1995
+
1954
1996
  /* 待验证那条对话末尾的**出路按钮**(人工确认完成 / 按审核意见返工重做,见 qaReqConvSnapshot):
1955
1997
  与卡片脚注同一套颜色语言(玉色 = 就这么定了,朱砂 = 再跑一轮),但对话里宽度充足、用全称。
1956
1998
  **贴右**(justify-content:flex-end):它作用于上面那一列审查内容(结论 + 报告都在右侧),
@@ -67,6 +67,9 @@ function normalizeEngineRow(r){
67
67
  /* 登录的账号昵称(如 Darren / 玲),选择器画在引擎名下面。 */
68
68
  account: r.account || "",
69
69
  modelsNote: r.models_note || r.modelsNote || "",
70
+ /* 模型来源语义(none / failed / dynamic,见 core --engines):选择器用它区分
71
+ 「引擎本身不提供模型」与「探测失败」—— 前者要保留(arkclaw / codebuddy-gateway)。 */
72
+ modelsSource: r.models_source || r.modelsSource || "",
70
73
  workspace: r.workspace || "",
71
74
  streaming: r.streaming,
72
75
  attachments: r.attachments || "",
@@ -469,31 +469,23 @@ function qaModelVendor(engines, engine, id){
469
469
  }
470
470
  /* 选择器里的模型顺序(**仅展示层**):
471
471
  ① `auto` 固定第一;
472
- ② 官方模型在前、自定义模型(有 model_vendors 的)整体在后;
473
- ③ 组内按积分倍率升序(便宜的在前),没有倍率的排在组尾;同倍率保持原顺序。 */
472
+ ② 其余按显示名**字母顺序**(不区分大小写,数字按自然序:`…-2` 在 `…-10` 前)。
473
+ 2026-10-26 用户:「引擎的显示现在是乱序的 按照字母顺序排」—— 之前按「官方在前 + 积分倍率」
474
+ 排,视觉上是一团乱;现在只按名字排,同一引擎的模型一眼能找。 */
474
475
  function qaModelsOrdered(engines, engine, models){
475
476
  const list = Array.isArray(models) ? models.slice() : [];
476
477
  if(list.length < 2) return list;
477
- const idx = new Map();
478
- list.forEach(function(m, i){ idx.set(m, i); });
479
- const creditOf = function(id){
480
- const raw = qaModelCredit(engines, engine, id).replace(/^×/, "");
481
- const n = parseFloat(raw);
482
- return isNaN(n) ? Infinity : n;
478
+ /* 自然序键:数字段补零到 12 位,`gpt-4` < `gpt-10`(纯字典序会反过来)。 */
479
+ const natKey = function(id){
480
+ return qaModelText(id, engine).toLowerCase()
481
+ .replace(/\d+/g, function(d){ return ("000000000000" + d).slice(-12); });
483
482
  };
484
483
  list.sort(function(a, b){
485
484
  if(a === "auto") return -1;
486
485
  if(b === "auto") return 1;
487
- const ca = !!qaModelVendor(engines, engine, a);
488
- const cb = !!qaModelVendor(engines, engine, b);
489
- if(ca !== cb) return ca ? 1 : -1;
490
- const x = creditOf(a), y = creditOf(b);
491
- if(x !== y){
492
- if(x === Infinity) return 1;
493
- if(y === Infinity) return -1;
494
- return x - y;
495
- }
496
- return idx.get(a) - idx.get(b);
486
+ const ka = natKey(a), kb = natKey(b);
487
+ if(ka === kb) return 0;
488
+ return ka < kb ? -1 : 1;
497
489
  });
498
490
  return list;
499
491
  }
@@ -1808,6 +1800,17 @@ function qaRenderAnswer(host){
1808
1800
  qaScrollIntoView();
1809
1801
  },
1810
1802
  setResult(text){ rerenderAll(text); },
1803
+ /** 把这一轮**已经画在气泡里的正文**整段撤掉(2026-10-25)。
1804
+ * 何时用:引擎把供应商的额度/限流通告**当正文**流了下来(codebuddy 这类只发聚合行的
1805
+ * 引擎:正文随 text 增量进气泡,收尾的 turn_failed 里 body 是空串),而真正的正文
1806
+ * 一个字都没有。此时 `setResult("")` 不够用 —— 它只在「只有一个正文块」时才覆盖
1807
+ * (见 rerenderAll 的多块守卫),而通告常常跟着思考/工具分成好几段。
1808
+ * ⚠️ 只撤正文块(`.qa-t`),思考块与工具组留着 —— 那是真的跑过的过程,不是假信息。 */
1809
+ clearText(){
1810
+ textBlocks.forEach(b=>{ if(b.parentNode) b.parentNode.removeChild(b); });
1811
+ textBlocks.length = 0;
1812
+ curText = null; pending = "";
1813
+ },
1811
1814
  toolCall,
1812
1815
  agentCall,
1813
1816
  askCard, // ⑤ 提问卡(AskUserQuestion → 可点选项)
@@ -1946,7 +1949,10 @@ function qaRenderDone(ok, label, host, elapsedMs, detail){
1946
1949
  // 失败终态:落成一张**设计过的错误卡**(见 29-msg-variants 的 qaErrorCardNode),
1947
1950
  // 而不是原来那行等宽灰字。成功 / 被停仍走下面那行轻量收尾(它们不需要卡片)。
1948
1951
  if(ok === false){
1949
- s.appendChild(qaErrorCardNode({ label: label, detail: detail, elapsedMs: t }));
1952
+ /* 额度 / 限流那一轮换一张**专门的卡**(2026-10-25,见 29-msg-variants 的 qaQuotaCardNode):
1953
+ 它要说的不是「出错在哪」,而是「什么时候能再用、现在该怎么办」—— 落成普通失败卡,
1954
+ 用户看到的是一段供应商原文。判据只在 core(事件带 error_kind),这里只分流。 */
1955
+ s.appendChild(qaQuotaCardNode({ label: label, detail: detail, elapsedMs: t, info: qaQuotaInfo(detail) }));
1950
1956
  qaScrollIntoView();
1951
1957
  return;
1952
1958
  }
@@ -429,6 +429,15 @@ function qaStreamApplyOne(ev, ctx){
429
429
  if(typeof h.setResult === "function") h.setResult(ev.text);
430
430
  /* 台账同源(同上:画面与台账都跟权威正文走,否则历史重画又冒出那句通告) */
431
431
  if(ctx && typeof ctx.onResult === "function") ctx.onResult(ev.text);
432
+ }else if(ev.error_kind){
433
+ /* 「这一轮只有一段通告、没有真正正文」那一档(2026-10-25 用户:「没额度了,设计一个提示卡」):
434
+ core 剥完通告后正文是空串,可流式时那段通告**早就拼进气泡了** —— 不管它,
435
+ 卡片下面会一直铺着「429 You have exceeded…」。这里把已画的正文整段撤掉,
436
+ 让画面只剩额度卡(过程块留着:那是真跑过的)。
437
+ ⚠️ 只在 ev.error_kind 非空时做:老 core 没有这个字段,空正文的 turn_failed
438
+ 仍按原样收尾(不动正文,避免把「引擎真没说完整」的场景擦成白板)。 */
439
+ if(typeof h.clearText === "function") h.clearText();
440
+ if(ctx && typeof ctx.onResult === "function") ctx.onResult("");
432
441
  }
433
442
  if(ev.sessionId || ev.session_id) ctx.onSession(ev.sessionId || ev.session_id);
434
443
  h.endThinking(); finishTools(false);