dsh-in-hand 1.5.2 → 1.7.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
@@ -36,8 +36,9 @@ metrics row above the composer.
36
36
  渲染成手机上看得懂的样子。目的很朴素——不用坐到电脑前,也能知道它现在在干什么、有没有
37
37
  事在等你。
38
38
 
39
- 后来发现同一组窄接口能接上 DSH **自己的**会话运行时,于是「写」只用了 5 条路由就够:
40
- 附件导入、对话、逐字收回复、随时停止、建会话与切换模型。**读是监控,写是对话**——两者共用一套令牌和
39
+ 后来发现同一组窄接口能接上 DSH **自己的**会话运行时,于是「写」只用了 6 条路由就够:
40
+ 附件导入、对话(含插队与硬打断)、逐字收回复、随时停止、建会话与切换模型、调整排队顺序。
41
+ **读是监控,写是对话**——两者共用一套令牌和
41
42
  一条反向隧道,始终不碰 GUI。
42
43
 
43
44
  所以这里没有 App 可装:没有原生代码、没有打包产物、没有应用商店。手机上进一次「添加到主
@@ -72,7 +73,7 @@ DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 `danger-full-acc
72
73
 
73
74
  - **读**:把状态渲染成一份手机看得懂的页面,只放行这一条路径就够。
74
75
  - **写**:`/taskwatch/chat/*` 把 DSH **自己的**会话运行时接到手机上 —— 直接对话、
75
- 逐字收回复、随时停止、切换会话与模型。写入口**只有 5 条**,全部列在下面。
76
+ 逐字收回复、随时停止、切换会话与模型、调整排队顺序。写入口**只有 6 条**,全部列在下面。
76
77
 
77
78
  采集面完全通过 `ctx.get(...)` 软读取服务,任何一个服务不存在都只会让对应的那一块
78
79
  留空,不会让插件挂载失败。对话那一组同理:拿不到 `sessionController` 时只让这一个
@@ -107,25 +108,46 @@ DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 `danger-full-acc
107
108
  | `/taskwatch/chat/titles?ids=` | JSON | 批量补会话标题(最多 20 个,宿主缓存 10 分钟) |
108
109
  | `/taskwatch/chat/page?...` | JSON | 往更早翻会话历史 |
109
110
  | `/taskwatch/chat/stream?sessionId=` | SSE | 跟随一个会话:开场快照 + 增量事件 + 逐字输出 |
111
+ | `/taskwatch/chat/queue?sessionId=` | JSON | 该会话未领取的消息:`items`(下一轮队列,可重排)与 `steering`(已插到本回合下一步) |
110
112
  | `/taskwatch/manifest.webmanifest` | JSON | PWA manifest |
111
113
  | `/taskwatch/sw.js` | JS | service worker |
112
114
  | `/taskwatch/icon-192.png` · `/taskwatch/icon-512.png` | PNG | 图标 |
113
115
 
114
- 写(`POST`)—— **只有这 5 条**:
116
+ 写(`POST`)—— **只有这 6 条**:
115
117
 
116
118
  | 路径 | 请求体 | 说明 |
117
119
  |---|---|---|
118
120
  | `/taskwatch/chat/upload` | 原始二进制(`sessionId/kind/name/mime` 查询参数) | 导入文件或图片;图片由宿主校验,文件按会话签发 receipt |
119
- | `/taskwatch/chat/send` | `{sessionId,text,attachments?,mode?,timeZone?}` | 给会话发文字和已上传附件 |
121
+ | `/taskwatch/chat/send` | `{sessionId,text,attachments?,mode?,now?,interrupt?,timeZone?}` | 给会话发文字和已上传附件;`now:true` 走宿主原生 steer 插队(见下) |
120
122
  | `/taskwatch/chat/cancel` | `{sessionId}` | 停止当前这一轮 |
121
123
  | `/taskwatch/chat/create` | `{}` 或 `{workspace}` | 新建会话;`workspace` 必须命中 `/taskwatch/chat/workspaces` 列出的路径 |
122
124
  | `/taskwatch/chat/model` | `{sessionId,provider,model}` | 切换该会话使用的模型 |
125
+ | `/taskwatch/chat/queue/reorder` | `{sessionId,ids:[...]}` | 重排下一轮队列;`ids` 必须是当前队列的完整 id 列表 |
123
126
 
124
127
  全部注册在 `ctx.webServer` 上,不代理 DSH 自身的 `/api`,也不转发任意路径。
125
- 写入口就只有上面 5 条 —— 契约测试会**正面清点**路由表,增删都会让构建失败
128
+ 写入口就只有上面 6 条 —— 契约测试会**正面清点**路由表,增删都会让构建失败
126
129
  (早先那条断言写的是"源码里不出现 `method === 'POST'`"。加了 5 个写路由之后它
127
130
  照样通过,是条假绿灯:它证明的是"没这么写",不是"没有这个能力")。
128
131
 
132
+ ### 排队、重排与「立即发送」的真实语义
133
+
134
+ 宿主本来就有两条投递通道:`mode:'queue'`(进 `nextTurn`,等下一轮)与
135
+ `mode:'steer'`(进 `nextStep`,在本回合下一个步骤边界被领取)。手机端:
136
+
137
+ - **排队** = 默认的 `queue`;`GET /taskwatch/chat/queue` 读 `nextTurn` 的顺序,
138
+ `POST /taskwatch/chat/queue/reorder` 用同一批 Message 整体重新 `splice`,
139
+ 不伪造消息、不改内容。
140
+ - **立即发送** = `send` 带 `now:true`,即宿主原生 `steer`。它进入 steering lane,
141
+ 排在本轮所有 `nextTurn` 排队消息之前,**但不会中止**正在执行的工具调用或模型
142
+ 请求 —— 消息在下一个 step 边界才被模型看到;会话空闲时会立刻唤醒并起一轮。
143
+ 同一 lane 里更早的 steering 消息仍按先来后到排在它前面。
144
+ - **硬打断** = 再带 `interrupt:true`:先 `cancel({keepInbox:true})`(队列保留),
145
+ 等 agent 真正 idle(最多 2 秒)再 steer;成了就先于保留的 `nextTurn` 队列执行
146
+ (cancel 前已有的 steering 仍按原顺序先跑)。2 秒内停不下来返回
147
+ `409 interrupt-timeout`,消息不发出、队列不动。
148
+ - **不会**做的事:不把队列顺序存在插件内存里(那会和宿主真实队列漂移),
149
+ 也不支持把 `steering` 消息重新排进 `nextTurn`。
150
+
129
151
  ### 手机能选工作区,但选不了任意目录
130
152
 
131
153
  「在手机上新开一个对话」得能指定在哪个目录开工,否则只能落在部署默认值上。但
@@ -331,6 +353,138 @@ dsh plugin --profile web add <你的绝对路径>/dsh-in-hand
331
353
  PWA 外壳由中继本地提供(而不是转发上游),这样它不随上游插件的版本变化而失效;
332
354
  页面文件与插件共用同一份,避免两边漂移。
333
355
 
356
+ ## 局域网直连(同一 Wi-Fi)
357
+
358
+ 上面那条路要域名、要 nginx、要一条 `ssh -R` —— 适合"人在外面也想看"。如果手机和电脑
359
+ 就在同一个屋子里,还有一条更短的路:**让中继直接监听内网地址,手机连同一个 Wi-Fi
360
+ 直连它。**
361
+
362
+ ### 怎么开(中继侧开关)
363
+
364
+ 中继默认只监听回环(`127.0.0.1`),连局域网都到不了,只有隧道能进。开局域网模式就是
365
+ 一个开关:在中继目录里放一个空的 `.lan` 标记文件,或者给它设环境变量 `RELAY_LAN=1`,
366
+ 它就从 `127.0.0.1` 改为绑定 `0.0.0.0`;删掉标记文件即关闭(这个开关是中继**启动时**
367
+ 读的,改完要重启它才生效)。中继自己的
368
+ `/taskwatch/lan` 页面(**不需要令牌,也不含任何凭据**)会列出当前是否已开启、本机可用
369
+ 的内网地址和连接步骤。
370
+
371
+ ### 手机怎么进
372
+
373
+ 1. 在跑中继的电脑上打开 `http://127.0.0.1:3099/taskwatch/pair`,页面上会显示一个
374
+ **6 位配对码**(只有从本机访问才显示)。
375
+ 2. 手机连同一个 Wi-Fi,打开 `http://192.168.1.20:3099/taskwatch`(把 `192.168.1.20`
376
+ 换成电脑的内网地址,以中继 `/taskwatch/lan` 列出的为准)。它会进配对页。
377
+ 3. 输入那 6 位码即可。配对码 **2 分钟有效、最多试 5 次、只能用一次**;换到的 cookie
378
+ 有效期 30 天,并且每次带着它访问都会滑动续期。
379
+
380
+ 页面右上角那个双入口胶囊也可以在这条路上用(见下一节)。
381
+
382
+ ### 这条路的硬限制
383
+
384
+ - **`http://` 不是安全上下文。** 装不成 PWA、没有 service worker 离线外壳,摄像头 /
385
+ 麦克风、剪贴板 API 也用不了。页面本身照常能用 —— 但"添加到主屏幕当一个 App 用"
386
+ 这件事只在 HTTPS 那条路上成立。
387
+ - **IP 会随 DHCP 变。** 路由器重新分配地址之后,手机上存的那条
388
+ `http://192.168.x.x:3099` 就失效了;以中继 `/taskwatch/lan` 当前列出的地址为准。
389
+ - **开启客户端隔离(AP isolation)的 Wi-Fi 连不上。** 那种设置下同一 Wi-Fi 的设备之间
390
+ 互相不可见,手机到不了电脑 —— 这是路由器的行为,不是这条路能绕开的。
391
+ - **出了这个 Wi-Fi 就连不上。** 要外网访问就用上面的隧道那条路。
392
+
393
+ ### 安全提醒
394
+
395
+ **拿到这条地址 + 配对码,等于拿到你机器上的一个终端。** 手机端能驱动 agent,而 DSH
396
+ 默认是 `danger-full-access`。所以:只在自己家、朋友家这类你信得过的网络里开;公司 /
397
+ 公共网络的 Wi-Fi 不要常开;不用的时候就删掉 `.lan` 标记文件,让它退回只监听回环。
398
+
399
+ 配对码这一层是刻意收紧的:它一次性,并且只在**本机 `127.0.0.1`** 的页面上显示 ——
400
+ 能操作这台电脑的人,才能授权一台手机。
401
+
402
+ ## 连接原理(实现路径)
403
+
404
+ ### 两条路
405
+
406
+ 远端(人在外面):
407
+
408
+ ```
409
+ 手机
410
+ └─ HTTPS https://dsh.example.com (示例域名,不是真实部署)
411
+ └─ nginx(服务器) 反向代理:TLS 终止
412
+ └─ SSH 反向隧道 ssh -R 本机主动连出,不需要公网 IP 与端口映射
413
+ └─ 本机中继 127.0.0.1:3099 校验中继令牌,按白名单转发
414
+ └─ DSH 上游 127.0.0.1:3080 /taskwatch/*(读 + /taskwatch/chat/* 写)
415
+ ```
416
+
417
+ 局域网(同一个 Wi-Fi):
418
+
419
+ ```
420
+ 手机
421
+ └─ HTTP http://192.168.1.20:3099/taskwatch 直连中继(中继绑定 0.0.0.0)
422
+ └─ 同一套 6 位配对码 → 30 天 cookie
423
+ └─ DSH 上游 127.0.0.1:3080
424
+ ```
425
+
426
+ ### 为什么用 SSH 反向隧道
427
+
428
+ 家里的机器通常在 NAT 后面:没有固定公网 IP,也没开入站端口。反向隧道是**本机主动连出**
429
+ 到服务器,服务器再顺着这条已经建立的连接把请求送回来 —— 所以不需要公网 IP,也不需要在
430
+ 路由器上做端口映射。服务器那一侧只多一个 nginx 反代负责 TLS 终止。
431
+
432
+ ### 令牌和 6 位配对码各解决什么
433
+
434
+ 两段路,两道锁,解的不是同一个问题:
435
+
436
+ - **中继令牌**(中继侧的 `RELAY_TOKEN`,或它自己生成在 `.token` 里的值)管的是
437
+ 「**服务器 → 本机中继**」这一段:证明这个请求是从被授权的隧道入口进来的。比较是定长
438
+ 的(`timingSafeEqual`),并且令牌不会被转发给上游。
439
+ - **6 位配对码**管的是「**手机 → 中继**」这一段:手机第一次访问时用它换一张 30 天、会
440
+ 滑动续期的 `HttpOnly` cookie。配对码 2 分钟有效、最多试 5 次、只能用一次,并且只在
441
+ **`127.0.0.1`** 的页面上显示。
442
+
443
+ ### 为什么中继看不出手机在哪个网络
444
+
445
+ 隧道那条路上,请求在中继眼里**来源都是 `127.0.0.1`** —— 它看到的对端是隧道,不是手机。
446
+ 所以中继分不清手机是在家里、在 4G,还是在同一个 Wi-Fi 里;同理,HTTPS 页面也没法探测
447
+ `http://` 内网地址(浏览器按混合内容拦掉,IP 主机连"可升级"都不算)。
448
+
449
+ 这就是手机页右上角那个双入口胶囊只能"事后切"、不能"事先测"的原因:
450
+
451
+ - 胶囊上显示当前走的是哪条路(远端 / 局域网),点一下在 **自动 → 固定局域网 →
452
+ 固定远端** 之间循环,选择记在 `localStorage`。
453
+ - **自动**只用两个信号:这条路刚才通不通、你上次选了哪条。远端路上
454
+ `GET /taskwatch/upstream-health` 体检失败(隧道或 DSH 挂了)就切局域网(若可用);
455
+ 局域网路上请求失败(手机离开了 Wi-Fi)就切回远端。能用的那条路不会被主动换掉。
456
+
457
+ ### 换路为什么不重输配对码
458
+
459
+ 换路等于换了一个**源**(`https://…` ↔ `http://192.168.x.x:3099`),而浏览器的 cookie 是
460
+ **按源隔离**的 —— 在远端那条路上换到的 cookie,到了局域网这条路等于不存在。
461
+
462
+ 所以页面点切换时,先向当前这条路的中继要一张凭据:
463
+
464
+ 1. `POST /taskwatch/ticket`(带令牌)→ 中继返回另一条路的 URL,末尾带一次性 `?t=…`;
465
+ 2. 手机跳过去,中继兑换这张票:**60 秒有效、兑换即作废、重放无效**,同时给那条路种下
466
+ cookie;
467
+ 3. 于是不用重新输那 6 位配对码。
468
+
469
+ 这三个接口都在中继侧,**中继不在本仓库**:`GET /taskwatch/entries`(入口清单与当前入口)、
470
+ `POST /taskwatch/ticket`(换路凭据)、`GET /taskwatch/upstream-health`(上游体检)。
471
+ 详细的实现与部署文档在中继仓库 **`dsh-in-hand-relay`**(该仓库尚未创建,此处只写仓库名)。
472
+ 中继没升级时,页面上的胶囊不会出现,其余功能不受影响。
473
+
474
+ > 注:上面「在外网 / 手机上访问」一节描述的是只转发读路径的只读中继;实际的中继同时
475
+ > 承载 `/taskwatch/chat/*` 的写入口(见上面的路由表)和本节这三个双入口接口。中继代码
476
+ > 不在本仓库。
477
+
478
+ ### 502 排查
479
+
480
+ 看到 502 先分清它从哪一段来,三种情况的处置完全不同:
481
+
482
+ | 现象 | 断在哪 | 说明 |
483
+ |---|---|---|
484
+ | 服务器 nginx 回的 502 | 隧道那一段 | `ssh -R` 断了,或者本机中继没在跑;重新拉起隧道 / 中继 |
485
+ | 中继回的 502,响应头带 `x-taskwatch-hop: relay` | 中继 → DSH 上游 | 中继自己发的:隧道是通的,但上游 DSH 不可达(回环端口没在监听,或插件没挂载) |
486
+ | 手机上**反复**看到 502 | 手机的 service worker | v1.6.0 之前的 SW 会把那次 502 存成外壳,之后离线导航一直吃它,而服务端早已恢复 —— v1.6.0 起只缓存 2xx,并把 `CACHE` 升到 v13 清掉已存的 502 |
487
+
334
488
  ## 安全边界
335
489
 
336
490
  - **写入口等于一台终端**。一旦放行 `/taskwatch/chat/send`,能打开这个页面的人就能在
package/lib/index.js CHANGED
@@ -830,6 +830,93 @@ export function apply(ctx) {
830
830
  return true
831
831
  }
832
832
 
833
+ // 新路由的错误统一带机器可读的 code,并给出可分辨的状态码:
834
+ // 400 参数不合法 / 404 会话不存在 / 409 队列不可用或已变化 /
835
+ // 501 这个宿主缺服务 / 502 宿主拒绝或抛错。
836
+ const failJson = (res, status, code, message, extra) =>
837
+ json(res, status, { error: message, code, ...(extra || {}) })
838
+
839
+ // ---- 消息队列 ---------------------------------------------------------
840
+ //
841
+ // 队列就是宿主 Agent.inbox 里还没被领取的输入,DSH 自己分成两份:
842
+ // nextTurn:等下一轮执行的排队消息(手机上显示为「队列」,顺序可调);
843
+ // nextStep:等本回合下一个 step 边界插入的 steering 消息(已经是插队,
844
+ // 不再参与排序)。
845
+ // 读与重排都直接走 dsh-agent 文档化的 Agent.inbox 接口;投递仍走
846
+ // sessionController.prompt({mode}),和 /taskwatch/chat/send 完全同一条路。
847
+ //
848
+ // 为什么重排不用 sessionController.updateQueue:它的 action 只有
849
+ // edit / remove / steer 三种,**没有换位置**。Inbox 的 splice 是宿主公开的
850
+ // 结构化接口(dsh-agent README「持久 inbox」一节),重排就是把同一批 Message
851
+ // 按新顺序原地重新 splice 一次 —— 不伪造消息、不改内容、不额外唤醒。
852
+ const agentsOf = () => {
853
+ const svc = ctx.get('agents')
854
+ return svc !== undefined && typeof svc.get === 'function' ? svc : undefined
855
+ }
856
+ const liveAgentOf = (sessionId) => {
857
+ const svc = agentsOf()
858
+ if (svc === undefined) return undefined
859
+ try { return svc.get(sessionId) } catch { return undefined }
860
+ }
861
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
862
+ // interrupt 用例必须等 agent 真正离开 running 再 steer:cancel 的 abort 还没
863
+ // 收敛时,ReactLoopAgent.send() 的 wakingAfterAbort 会把 steer 改投到
864
+ // next-turn **末尾**,插队就悄悄失效了。宁可 409 也不假装成功。
865
+ const waitIdle = async (agent, timeoutMs) => {
866
+ const deadline = Date.now() + timeoutMs
867
+ while (Date.now() < deadline) {
868
+ if (agent.status !== 'running') return true
869
+ await sleep(50)
870
+ }
871
+ return agent.status !== 'running'
872
+ }
873
+ // 区分「会话不存在」(404)和「会话存在但当前没有 live agent,队列不可读」
874
+ // (409)。返回 false 表示确证不存在,null 表示没法确认(按不可用处理)。
875
+ const sessionExists = async (sessionId) => {
876
+ const store = ctx.get('sessions')
877
+ if (store !== undefined && typeof store.get === 'function') {
878
+ try { if (store.get(sessionId) !== undefined) return true } catch { /* 继续找 */ }
879
+ }
880
+ const persistence = ctx.get('sessionPersistence')
881
+ if (persistence !== undefined && typeof persistence.stat === 'function') {
882
+ try { if (await persistence.stat(sessionId)) return true } catch { /* 继续找 */ }
883
+ }
884
+ try {
885
+ const value = await chatOf().list({}, ctl().signal)
886
+ return !!(value && Array.isArray(value.items)
887
+ && value.items.some((s) => String(s.sessionId) === sessionId))
888
+ } catch {
889
+ return null
890
+ }
891
+ }
892
+ const queueText = (message) => {
893
+ const text = blocksText(message && message.content)
894
+ return text.length > 2000 ? text.slice(0, 2000) + '…(已截断)' : text
895
+ }
896
+ const queueItemOf = (message, index) => {
897
+ const content = message && Array.isArray(message.content) ? message.content : []
898
+ return {
899
+ id: String(message && message.id ? message.id : ''),
900
+ index,
901
+ text: queueText(message),
902
+ images: content.filter((b) => b && b.type === 'image').length,
903
+ files: content.filter((b) => b && b.type === 'file').length,
904
+ source: asText(message && message.source ? message.source.kind : ''),
905
+ }
906
+ }
907
+ const MAX_QUEUE_ITEMS = 200
908
+ const queueViewOf = (agent) => {
909
+ const turn = Array.isArray(agent.inbox.nextTurn) ? agent.inbox.nextTurn : []
910
+ const step = Array.isArray(agent.inbox.nextStep) ? agent.inbox.nextStep : []
911
+ return {
912
+ running: agent.status === 'running',
913
+ items: turn.slice(0, MAX_QUEUE_ITEMS).map(queueItemOf),
914
+ steering: step.slice(0, MAX_QUEUE_ITEMS).map(queueItemOf),
915
+ counts: { queued: turn.length, steering: step.length },
916
+ truncated: turn.length > MAX_QUEUE_ITEMS || step.length > MAX_QUEUE_ITEMS,
917
+ }
918
+ }
919
+
833
920
  // 原始二进制上传:文件由 fileUploads 按 Session 暂存,图片由 attachments
834
921
  // 先做图像准入再用一次性 token 交给后续 prompt。请求体硬上限沿用交付文件
835
922
  // 的 32 MiB 边界,名称只作为显示名传给宿主,绝不解释为路径。
@@ -1024,6 +1111,18 @@ export function apply(ctx) {
1024
1111
  }), 'in-hand: chat create route')
1025
1112
 
1026
1113
  // 发消息。requestId 由宿主生成:客户端不该能决定请求身份。
1114
+ //
1115
+ // now:true 的语义(宿主原生能力,不模拟):
1116
+ // - 不打断:走 mode:'steer'。消息不进 nextTurn 队列,而是进 nextStep
1117
+ // (steering lane),在本回合**下一个 step 边界**被领取 —— 排在本轮所有
1118
+ // nextTurn 排队消息之前;若已有更早的 steering 消息,它们仍按先来后到
1119
+ // 排在前面(同一 lane 不改序)。会话空闲时 steer 会直接唤醒 agent,
1120
+ // 立刻起一轮。正在执行的工具调用或模型请求不会被中止。
1121
+ // - 要硬打断:再带 interrupt:true。先 cancel({keepInbox:true})(队列保留),
1122
+ // 等 agent 真正 idle(最多 2 秒)再 steer;新消息进入新回合的 steering
1123
+ // lane,先于保留的 nextTurn 队列。cancel 前就存在的 steering 消息仍会
1124
+ // 按原顺序先跑。没停下来就 409,消息不发出、队列不动。
1125
+ // 兼容旧的 mode:'steer' 写法:它等价于 now:true 但不会 cancel。
1027
1126
  ctx.effect(() => webServer.register({
1028
1127
  kind: 'exact',
1029
1128
  path: '/taskwatch/chat/send',
@@ -1031,10 +1130,13 @@ export function apply(ctx) {
1031
1130
  if (!requirePost(req, res, 'application/json')) return
1032
1131
  const lifetime = requestLifetime(req, res)
1033
1132
  const locked = []
1133
+ let nowRequested = false
1034
1134
  try {
1035
1135
  const body = await readJson(req)
1036
1136
  const sessionId = asText(body.sessionId)
1037
1137
  const text = asText(body.text)
1138
+ const wantNow = body.now === true
1139
+ const wantInterrupt = wantNow && body.interrupt === true
1038
1140
  if (!sessionId) throw new Error('缺少 sessionId')
1039
1141
  if (body.attachments !== undefined && !Array.isArray(body.attachments)) throw new Error('附件格式无效')
1040
1142
  const uploads = body.attachments || []
@@ -1086,10 +1188,33 @@ export function apply(ctx) {
1086
1188
  continue
1087
1189
  }
1088
1190
  }
1191
+ // interrupt 只对正在跑的回合适用;空闲会话没有可打断的工作,
1192
+ // 直接 steer 就会立刻起一轮,不必也无从 cancel。
1193
+ if (wantInterrupt) {
1194
+ const active = liveAgentOf(sessionId)
1195
+ if (active !== undefined && active.status === 'running') {
1196
+ const service = chatOf()
1197
+ if (typeof service.cancel !== 'function') {
1198
+ failJson(res, 501, 'host-unsupported', '宿主不支持取消当前回合,无法立即发送')
1199
+ return
1200
+ }
1201
+ try {
1202
+ await service.cancel({ sessionId })
1203
+ } catch (e) {
1204
+ failJson(res, 502, 'host-rejected', '打断当前回合失败:' + why(e))
1205
+ return
1206
+ }
1207
+ if (!(await waitIdle(active, 2000))) {
1208
+ failJson(res, 409, 'interrupt-timeout', '当前回合没能在 2 秒内停下,消息未发送')
1209
+ return
1210
+ }
1211
+ }
1212
+ }
1213
+ nowRequested = wantNow
1089
1214
  const request = {
1090
1215
  requestId: randomUUID(),
1091
1216
  sessionId,
1092
- mode: body.mode === 'steer' ? 'steer' : 'queue',
1217
+ mode: (wantNow || body.mode === 'steer') ? 'steer' : 'queue',
1093
1218
  content,
1094
1219
  }
1095
1220
  if (asText(body.timeZone)) request.clientTimeZone = asText(body.timeZone)
@@ -1098,7 +1223,12 @@ export function apply(ctx) {
1098
1223
  for (const { key, record } of locked) if (pendingUploads.get(key) === record) pendingUploads.delete(key)
1099
1224
  if (!res.destroyed && !res.writableEnded) json(res, 200, { ok: true, accepted: true })
1100
1225
  } catch (e) {
1101
- if (!res.destroyed && !res.writableEnded) json(res, 200, { error: why(e) })
1226
+ if (!res.destroyed && !res.writableEnded) {
1227
+ // 老客户端只认 200 + {error},保持原契约;带 now 的新语义失败用 502,
1228
+ // 让调用方能和「参数不对」(仍是 200 + 文本)分开。
1229
+ if (nowRequested) failJson(res, 502, 'host-rejected', '立即发送失败:' + why(e))
1230
+ else json(res, 200, { error: why(e) })
1231
+ }
1102
1232
  } finally {
1103
1233
  for (const { record } of locked) record.sending = false
1104
1234
  lifetime.dispose()
@@ -1123,6 +1253,120 @@ export function apply(ctx) {
1123
1253
  },
1124
1254
  }), 'in-hand: chat cancel route')
1125
1255
 
1256
+ // 查询一个会话的消息队列。只回纯标量(id / 文本 / 附件计数 / 来源),
1257
+ // 绝不把 live 的 Message 对象或图片字节带出去。
1258
+ ctx.effect(() => webServer.register({
1259
+ kind: 'exact',
1260
+ path: '/taskwatch/chat/queue',
1261
+ handler: async (req, res) => {
1262
+ if (req.method !== 'GET') {
1263
+ res.setHeader('allow', 'GET')
1264
+ failJson(res, 405, 'method-not-allowed', '只接受 GET')
1265
+ return
1266
+ }
1267
+ try {
1268
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
1269
+ const sessionId = asText(url.searchParams.get('sessionId')).trim()
1270
+ if (!sessionId) { failJson(res, 400, 'bad-request', '缺少 sessionId'); return }
1271
+ if (agentsOf() === undefined) {
1272
+ failJson(res, 501, 'host-unsupported', '宿主没有 agents 服务,消息队列不可用')
1273
+ return
1274
+ }
1275
+ const agent = liveAgentOf(sessionId)
1276
+ if (agent === undefined) {
1277
+ const exists = await sessionExists(sessionId)
1278
+ if (exists === false) { failJson(res, 404, 'session-not-found', '会话不存在'); return }
1279
+ failJson(res, 409, 'queue-unavailable', '会话当前没有活动的 agent,消息队列不可用')
1280
+ return
1281
+ }
1282
+ json(res, 200, { ok: true, sessionId, ...queueViewOf(agent) })
1283
+ } catch (e) {
1284
+ failJson(res, 502, 'host-rejected', '读取消息队列失败:' + why(e))
1285
+ }
1286
+ },
1287
+ }), 'in-hand: chat queue route')
1288
+
1289
+ // 重排 nextTurn(下一轮队列)。ids 必须是当前队列的**完整** id 列表:
1290
+ // 少了、多了、重复或包含 steering 消息都会 409 并要求客户端刷新。
1291
+ // 这是有意的 —— 只按客户端看到的快照整体替换,避免「把已经被领走的消息
1292
+ // 又插回去」这类不可见的数据错乱。
1293
+ ctx.effect(() => webServer.register({
1294
+ kind: 'exact',
1295
+ path: '/taskwatch/chat/queue/reorder',
1296
+ handler: async (req, res) => {
1297
+ if (!requirePost(req, res, 'application/json')) return
1298
+ try {
1299
+ const body = await readJson(req)
1300
+ const sessionId = asText(body.sessionId).trim()
1301
+ if (!sessionId) { failJson(res, 400, 'bad-request', '缺少 sessionId'); return }
1302
+ if (!Array.isArray(body.ids)) {
1303
+ failJson(res, 400, 'bad-request', 'ids 必须是消息 id 数组')
1304
+ return
1305
+ }
1306
+ const ids = body.ids.map((id) => asText(id))
1307
+ if (!ids.length || ids.some((id) => !id)) {
1308
+ failJson(res, 400, 'bad-request', 'ids 必须是非空的消息 id 数组')
1309
+ return
1310
+ }
1311
+ if (new Set(ids).size !== ids.length) {
1312
+ failJson(res, 400, 'bad-request', 'ids 里有重复的消息')
1313
+ return
1314
+ }
1315
+ if (agentsOf() === undefined) {
1316
+ failJson(res, 501, 'host-unsupported', '宿主没有 agents 服务,消息队列不可用')
1317
+ return
1318
+ }
1319
+ const agent = liveAgentOf(sessionId)
1320
+ if (agent === undefined) {
1321
+ const exists = await sessionExists(sessionId)
1322
+ if (exists === false) { failJson(res, 404, 'session-not-found', '会话不存在'); return }
1323
+ failJson(res, 409, 'queue-unavailable', '会话当前没有活动的 agent,消息队列不可用')
1324
+ return
1325
+ }
1326
+ // 从这里到 splice 全部同步:中间不能 await,否则读到的队列和写回的队列
1327
+ // 可能不是同一份(消息被领取后,旧对象会被重新插回去)。
1328
+ let current
1329
+ try {
1330
+ current = Array.from(agent.inbox.nextTurn)
1331
+ } catch (e) {
1332
+ failJson(res, 409, 'queue-unavailable', '这个会话的队列状态读不到:' + why(e))
1333
+ return
1334
+ }
1335
+ const currentIds = current.map((m) => String(m && m.id))
1336
+ // 只重排普通用户提示词。目标轮次 / 子代理续跑 / 提问回复这类消息在
1337
+ // 宿主里有各自的 discarded 副作用(重排会产生一次 discard + insert),
1338
+ // 不去碰它们;手机上这些行的 source 会显示出来,由客户端禁用排序。
1339
+ const foreign = current.find((m) => !(m && m.source && m.source.kind === 'user'))
1340
+ if (foreign) {
1341
+ failJson(res, 409, 'queue-unavailable', '队列里有非用户消息(目标轮次/子代理/提问回复),暂不支持重排')
1342
+ return
1343
+ }
1344
+ const sameSet = currentIds.length === ids.length
1345
+ && ids.every((id) => currentIds.indexOf(id) >= 0)
1346
+ if (!sameSet) {
1347
+ failJson(res, 409, 'queue-changed', '队列已经变化(有消息被领取或新增),请刷新后重试', { current: currentIds })
1348
+ return
1349
+ }
1350
+ const changed = ids.some((id, i) => id !== currentIds[i])
1351
+ if (changed) {
1352
+ const byId = new Map(current.map((m) => [String(m.id), m]))
1353
+ const ordered = ids.map((id) => byId.get(id))
1354
+ try {
1355
+ agent.inbox.splice('next-turn', 0, current.length, ordered)
1356
+ } catch (e) {
1357
+ failJson(res, 502, 'host-rejected', '宿主拒绝重排:' + why(e))
1358
+ return
1359
+ }
1360
+ }
1361
+ json(res, 200, {
1362
+ ok: true, sessionId, changed, ids: ids.slice(), ...queueViewOf(agent),
1363
+ })
1364
+ } catch (e) {
1365
+ failJson(res, 502, 'host-rejected', '重排失败:' + why(e))
1366
+ }
1367
+ },
1368
+ }), 'in-hand: chat queue reorder route')
1369
+
1126
1370
  // 会话日志的流式跟随(SSE)。
1127
1371
  //
1128
1372
  // follow() 返回的本来就是**为传输设计的 wire 帧**(SessionWireEvent.data 是