Skip to content

dsh-session-ball-plugin

Verified

dsh-session-ball-plugin · v0.7.0 · MIT

⚠ 这是一个仍在持续更新的插件:版本号会不断往前走,能力与行为都可能变,请按最新版取用。它只为一件事而做——让「在后台运行的任务」拥有前端反馈:任务在后台跑(尤其是模型开始操作桌面、网页端失去前台位置时)你依然看得见它在做什么、是不是在等你。具体是一个原生 Windows 置顶悬浮球,显示状态与会话/回合信息,把「提问 / 审批」搬到球上直接作答(网页端同时保留,哪边先答以哪边为准,另一边的卡片随即收起,超时未答自动交回网页端),并可以在球上直接输入给模型发话:面板底部的「发送到」下拉可以跟随球上显示的会

Install

dsh plugin add dsh-session-ball-plugin

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Published to npm without a public repository. Inspect the package contents before installing.

Tags

Readme

dsh-session-ball-plugin · 会话悬浮球

这是一个仍在持续更新的插件。 版本号会不断往前走,能力、配置项与行为都可能变, 请以最新版为准(本文件描述的就是当前这一版)。

它只为一件事而做:让「在后台运行的任务」拥有前端反馈。 任务在后台跑的时候 (尤其是模型开始操作桌面、网页端失去前台位置的那段时间),你需要一个不依赖于浏览器、 不依赖于前台窗口的东西告诉你:它现在在做什么、跑到哪一步了、是不是在等你回答。 这个悬浮球就是那个东西——状态、会话与回合信息、提问与审批的作答入口、以及直接在球上 给模型发话的输入框,全都浮在所有窗口之上。

这个包以前叫 dsh-thinking-ball-plugin(思维悬浮球)。球上早就不显示思维链与正文了, 后来连工具调用内容也去掉,名字里那个 "thinking" 就彻底对不上了 —— 所以包名、目录名、 行 id(thinking-ball → session-ball)与运行目录一起改成了 session-ball。 改名后要重新安装一次(profile 里那条依赖指向新目录),并且 Profile patch 里如果还写着 - id: thinking-ball,要改成 - id: session-ball。

一个原生 Windows 置顶悬浮球,显示 DeepSeek Harness 里模型正在做什么:状态灯、会话与回合 信息;模型提问或请求审批时高亮提醒,可以直接在球上作答(选选项、也可以自己写一句), 也可以在球上直接给模型发话——面板底部的输入框,配一个「发送到」下拉:跟随球上显示的会话、 挑一个旧会话(每行的两行小字就是它的身份)、或者在某个工作区里新建一个会话。发出去之后 面板不动:输入框留着、下一句可以接着打;而目标会话正在输出的那段时间里,输入框与发送 按钮是禁用的——点完发送当场变灰、水印换成「已发出,等会话接手…」,会话跑完自己恢复,这就是 「这句话发出去了没有」的反馈(见下「发送之后看得见的那一下」)。

它解决的问题很具体:Harness 的 Web UI 是浏览器里的一个页面。模型一开始操作桌面 (desktop_step 会真的去动你的鼠标键盘),页面就失去前台位置,提问、审批全都被 挡在后面 —— 你不知道它在想什么、是不是卡住了、有没有在等你回答。页面里的任何元素都 做不到"浮在别的窗口之上",所以这个球是另一个进程里的原生窗口(Windows PowerShell + WPF), 由插件从 Host 侧喂数据。

┌─ 收起 ─┐        ┌──────────── 展开 ─────────────────────┐
│   ●DS   │  单击   │ ● 工具执行中        会话标题           │
│  (带徽标) │ ─────▶ │ modelscope / V4.1 · 第 3 轮第 2 步     │
└────────┘        │ ┌ 模型在等你回答 ────────────────────┐ │
  拖动可移动        │ │ [保存位置] 要放到哪个目录?        │ │
                  │ │ ● C:\work\out  ○ D:\备份  ○ 自定义 │ │
                  │ │ ┌────────────────────────────────┐ │ │
                  │ │ │ 也可以直接在这里输入你的回答…   │ │ │
                  │ │ └────────────────────────────────┘ │ │
                  │ │ [在球上作答]  [先收起来]           │ │
                  │ └────────────────────────────────────┘ │
                  │ 发送到 [跟随球上显示的会话:…      ▾]  │
                  │ [在这里输入,回车发给模型……]  [发送]   │
                  │ 拖动面板可移动 · 点右上角「-」收起      │
                  └────────────────────────────────────────┘

球上只显示"状态",不显示"内容"。 它是你在模型操作桌面时瞥一眼的东西,一整面滚动的文字 既挡住屏幕,又把真正需要你决定的那两件事(提问、审批)淹掉。所以:

  • 思维链与正文从来不显示,Host 侧也不把它们塞进状态文档;
  • 工具调用内容(工具名之外的参数摘要、结果摘要)也不再显示 —— 那张卡片连同它的高度一起 去掉了,展开面板因此从 400×380 缩到 360×320。工具名本身只剩"工具执行中"这个状态灯, 以及失败时 meta 行上的一句原因(那已经不是内容,是结论)。

面板留下的就是:状态、会话信息、待办卡片、发送行。

面板有多高

有多少内容就占多高:panelWidth 是最终宽度,panelHeight 是上限。没有待办卡片时, 面板自己缩到状态 + 发送行那么高(本机实测 360×183,meta 行折成两行时 199),中间不再留一块 空白 —— 那正是工具卡片去掉之后留下的一块空地。卡片多起来就长到 panelHeight 为止,再多的 卡片在面板里自己滚动,和以前一样。

这件事由球的脚本量出来(resources/ball.ps1 的 Get-PanelHeight):把面板按"不限高度" 测一遍,把结果夹在下限 120 与 panelHeight 之间。三个坑都是实测踩出来的,注释也写在函数上面:

  1. Height="*" 那一行在不限高时不要内容的高度(量的时候得临时当 Auto 行);
  2. WPF 会拿缓存的测量结果回答同一个约束(得先把子树整体标脏,见 Reset-Measure);
  3. 标脏只能标到 UIElement —— 视觉树里还有不是 UIElement 的东西:一个真的渲染过的 TextBox 会带内部的绘制视觉(TextBoxLineDrawingVisual,光标与选区),它没有 InvalidateMeasure。第一次就是这么翻的:每份状态都在这里抛异常,面板停在收起球的几十像素 高度(输入框与卡片全被裁到窗口外),而且同一份状态文档被无限重试(5 分钟 1261 次)。

所以这一步现在失败也不牵连别人:量不出来就退回"和以前一样高的固定面板"(panelHeight, 只记一次日志);Apply-State 抛异常也只影响它自己那一份文档。

量必须发生在改窗口尺寸之前,而且一份状态文档只改一次尺寸(Get-PanelHeight 只负责量, Apply-PanelHeight 与 Apply-Shape 负责改)。这条顺序是 0.6.6 修的,用户报的症状是"点开 时能捕捉到一瞬间它展开得比较大,然后瞬间变小":更早的展开路径先把窗口按 panelHeight (320 —— 也就是工具卡片还在时那个固定面板的高度)撑开,再量、再把量出来的 ~183 盖回去; 同一次调用里于是出现了两个尺寸,而合成器完全可能先送出一帧 320 的那一个。现在展开是 "量完再一次给宽高",而且给宽高那两条语句之间窗口里什么都没有(球已经收起了,面板也临时 收起,整扇窗口是透明的),所以没有一帧可能被合成成"错的尺寸";Apply-State 也改成先把这份 文档的所有文字(卡片、下拉、输入行、meta 与页脚)写完,再让形状去量 —— 于是"状态一到就 展开"(expandOnPending)那一格也不会先量到上一份文档的内容。

自测(一次性的替身球)与真机各自的证据:替身球的每一轮展开都只有一条尺寸记录、落点是内容 高度(无卡片 360×183)而不是 320 上限,且展开后截图取样确认面板真的画出来了(换尺寸那 两步是把面板临时收起来的,这一条专门盯的是一个可能的低级错误:尺寸对了、面板却忘了显示); 真机上连续十几次"收起→展开"里,只要面板上没有卡片,日志每一轮都是一行 expanded at 360x199 —— 320 只在真有卡片把内容顶到上限时出现。

置顶是"带"不是"位":球为什么会消失,以及那条看门狗

球靠置顶活着:浏览器、编辑器、记事本怎么开都盖不住它。但"置顶"在 Windows 上是两个东西:

  • 扩展样式里的 WS_EX_TOPMOST 位(GetWindowLong 读得到);
  • 窗口真正所在的置顶带(topmost band)—— 决定谁盖住谁的是它,而位不是带:两者可以 不一致,而且真的不一致了。

用户报的"一段时间后悬浮球消失"实测就是这样:那个球窗口带着置顶位、IsWindowVisible 为真、 位置也没错,却排在 z 序第 58 位 —— 在一张最大化的浏览器后面;在球自己的中心点上 WindowFromPoint 返回的是浏览器的渲染窗口,也就是说它既看不见、也点不到(进程还在、状态文件 还在写、ball.log 里什么都没有:它没崩,只是被盖住了)。

是谁把它弄出带外的,这个脚本答不出来,也不用答。实测(用一次性的替身球做的自测): 把一个置顶窗口插到普通窗口之上、以及 HWND_BOTTOM,两种走法都会顺手把位也清掉;而 SetWindowLong 连位都写不回去(系统会把它剥掉)—— 位和带都只归 SetWindowPos 管。 所以修法不是"换个调用点",而是看门狗,它只问 z 序:

  • Assert-BallTopmost(0.6.6):每 1.5 秒从球自己的窗口沿 z 序往上走,撞到第一个可见 且不带置顶位的窗口就说明球被盖住了,这时才 SetWindowPos(HWND_TOPMOST) 把位和带一起 要回来,并往 ball.log 写一行(下次再发生时,日志里就有它自己的时间点)。往上走而不是从 桌面顶端往下读整条链:健康时第一步就会撞上别的置顶窗口(任务栏、别的浮层)然后停下;中间 那些不可见的窗口(本进程的 IME/TSF 辅助窗口、WPF 的隐藏宿主窗口)与本进程自己的可见 窗口(展开中的「发送到」下拉就是一个,它本来就该在球上面)一律跳过,否则每一拍都会误判、 每一拍都要补一次;
  • 只在真的被盖住时才出手:无条件定时置顶会把球压到别的置顶窗口(always-on-top 的 播放器、任务栏自己的弹窗)上面去,那不是这个插件该做的决定;
  • Update-WindowStyles —— 球脚本里唯一改写扩展样式的地方 —— 改完总是紧接着补一次 SetWindowPos:这里不做判断,因为它就是那唯一一处动样式的地方。

自测结果(一次性的替身球,多轮):健康的球零误报;HWND_BOTTOM、"插到某个普通窗口之上" 这类被移出带外的形态都在 1.5 秒内被发现并放回(放回后那个位也回来了)。真机上把这条看门狗 第一次用出来,就是修好当下那个球的时刻:z 序第 58 位 → 第 15 位,球中心那一点重新落回球自己。

代价与边界:它只认"可见 + 不带置顶位",所以另一个置顶窗口盖住球时它不动手(那是别人的 权利);反过来,任何非置顶窗口都再也盖不住球 —— 包括被最大化或全屏的应用,这正是它该 做的。UAC 的安全桌面、锁屏、任务视图这类地方,球进程根本看不到那些窗口,看门狗也就不会跟着 乱动。

这一条同样只改球脚本:杀掉球进程,宿主 2 秒内会用新脚本重新拉起它。

位置与拖动

  • 两种形状都能拖。收起的小球整块都能拖;展开后的面板,任何不是控件的地方都能拖 (标题行、meta 行、卡片之外的空白、面板底部那行提示)。按钮与输入框照旧只做自己的事: 它们自己会吃掉鼠标按下,所以拖不动它们 —— 这是刻意的,想在面板上点按钮时才不会误触。
  • 初始位置在工作区右下角(离右/下边缘各 24px),不在右上角:右上角住着浏览器标签、文档 页首与网页端自己的顶栏,而任务栏之上那一片通常是空的。
  • 位置只记一份,记的是"收起的小球"落在哪(window.json)。所以:展开、收起、拖动面板 之后,球都回到那个角上 —— 既不是回到"面板左上角",也不会每展开一次就往上爬一点(面板贴 着屏幕底边时展开会被系统往上顶,收起时球要回到自己原来那一角,而不是继承那次上顶)。
  • 拖出屏幕的那一下会被拉回虚拟桌面内:否则面板的头连同右上角那个收起按钮就永远点不到了。 报到宿主那里的也是钳位之后的位置。
  • 位置只在第一次运行时用默认值;之后你把它拖到哪,它就在哪(window.json),换 Profile 或 删掉那个文件才会回到默认。

小球的几何:环要压住球缘,光晕不能贴着窗口边

收起的小球是三个同心圆:底色球体(ballSize)、状态色圆环(跟着 running/thinking/tool/… 换颜色)、以及只有模型在跑的时候才出现的白色光晕(BallGlow,比球体每边大 7px)。

两处"贴着边"的写法是 0.6.2 修掉的,症状都是球看起来在右侧和下侧被切了一刀:

  1. 圆环原来是齐着球缘的(环的直径等于球体,Margin="1" 又被 Apply-Shape 写的显式 尺寸抵消掉了)。环的外缘与球缘重合时,只要布局把它落在半个像素上,深色球缘就会在某一侧 露到环外面 —— 看起来就是"右下的弧缺了一截、黑球鼓出来一块"。现在环比球体宽 2px, 外缘每边高出球缘 1px,球缘再也露不出来(实测:改前正右/正下最外侧像素是球体色,改后 四个方向最外侧都是环色)。
  2. 光晕原来是正好填满窗口的(球体 64 + 每边 7 = 78 = 窗口 78),四条边都贴着窗口沿。 窗口尺寸现在由 Get-BallWindowSize 统一算(ballSize + 18,即光晕的 7px 之外再留 2px 余量),收起窗口因此是 82×82 而不是 78×78 —— 多出来的 2px 每边都是透明的,不挡鼠标 (BallView 之外的区域本来就不参与命中测试),只是让光晕不再有任何可能被窗口边缘削掉。

这一条只改球脚本,所以改完只要重启球就生效(杀掉球进程,宿主 2 秒内会用新脚本拉起来), 不需要重启 Harness。

装与开

# 在 Harness Web UI:Plugins → Add plugin → 填这个绝对路径
C:\DSH\dsh-session-ball-plugin

或者命令行(等价,由 plugin_manager 完成安装与启用):

plugin_manager  install_bundle  target = C:\DSH\dsh-session-ball-plugin

开启后立即生效,不需要重启 Harness —— 唯一例外见下面「什么时候要重启」。

  • 运行目录:%LOCALAPPDATA%\dsh-session-ball\<profile 或 default>\
    • state-<n>.json:宿主 → 球的状态(每次写一份新文件,球取序号最大的那份)
    • act-<pid>-<seq>.json:球 → 宿主的动作(一个动作一个文件,宿主消费后删除)
    • <sha1-12>-ball.ps1:当前这一代球脚本(内容哈希命名,见下)
    • window.json:球的位置与展开状态(拖动/展开后记住)
    • ball.log:诊断日志(宿主与球都往里写)
    • debug.flag:放一个空文件进去,下次球启动时打开交互追踪
  • 关掉:把这一行的 config.enabled 设为 false,或者 disabled: true;两者都会杀掉球进程。
  • 改名之后:运行目录也跟着换了名字,所以球的窗口位置回到默认值(现在这个默认值就是右下角), 旧的 %LOCALAPPDATA%\dsh-thinking-ball\ 可以整个删掉(插件不再读它)。

配置

写在 row 的 config 里(例如你自己的 Profile patch,或 Plugins 页面上的表单):

键 默认 说明
enabled true 总开关
ballSize 64 收起时的直径(像素)
panelWidth / panelHeight 360 / 320 展开面板的宽度与最大高度(工具卡片去掉之后从 400×380 缩小)。高度是上限:没有待办卡片时面板自己缩到内容那么高(见上「面板有多高」),卡片多了就长到这个数为止,再多的卡片自己滚动
opacity 0.94 常态不透明度(球的样子只有这一个旋钮,且它不随模型在做什么而变)
answerQuestions true 允许在球上回答提问
answerApprovals true 允许在球上批准/拒绝审批
sound true 出现提问/审批时响一声
expandOnPending false 出现提问/审批时自动摊开面板。默认关:球的变色与徽标数字就是提醒,面板由你自己点开(见下「球不自己摊开」)
promptMode "auto" 球上输入框怎么把话投进会话:auto = 模型在跑就插话(steer)、空闲就排队(queue);也可以固定成 queue / steer
lockComposeWhileBusy true 目标会话正在输出期间禁用球上那个输入框(发送之后输入框变灰="这句话收到了",见下「发送之后看得见的那一下」)。关掉它就不会因为"会话在输出"而锁(点完发送的那一两秒仍会锁一下:那是球自己不等宿主回话的短锁),代价是没有"输出期间一直灰着"这一层反馈
targetCount 15 「发送到」下拉里最多列几个旧会话(越新的越靠前)

球不让开

球在任何时刻都不改变自己的样子与可点性:模型操作鼠标键盘时,它照样停在原地、照样可以点; 不透明度、大小、可点性都不随模型在做什么而变(opacity 是与样子有关的唯一旋钮,而它是常量)。 代价是两条,都需要知情:

  1. 球会出现在 desktop_step 返回的截图里(任何置顶窗口都躲不掉这一点);
  2. 模型如果点到球所在的那几像素上,那一下会落在球上而不是它想点的窗口上。

介意就把球收起来再开始桌面任务。这里曾经有一个 shrinkWhileDesktop 开关(连同 desktopOpacity 与 desktopTools 两个配套键),会在有工具驱动键鼠期间把球变半透明 + 鼠标穿透 + 缩到最小;那一套 已经整个删除,三个键在新版里是无效键(写了也不会报错,只是没有任何作用)。

键盘的常态是安全的:窗口用 WS_EX_NOACTIVATE 创建,球不拿键盘焦点,模型敲的字进不了球。 唯一的例外是你亲手点开的那个输入框 —— 那一段时间里球是前台窗口,所以模型若正好在注入按键, 那些字会落进这个框里(连回车也会被当成"发送")。球不再为此让步:它不看别的工具在做什么 (来龙去脉见下「键盘」)。

在球上直接说话(发送行)

面板底部那一行是输入框:点一下、打字、回车发送。发出去的话会变成目标会话的一条普通 用户消息,与你在网页端输入框里发的一模一样(来源、去重、排队/插话、模型与附件校验全都 同一套),因为它走的就是网页端那个入口 ctx.sessionController.prompt。

发给谁:「发送到」下拉

下拉里的每一行都是两行:主文案说这是谁,下面一行小字说它到底是哪一个 (是否在跑 · 多久以前 · 目录 · 会话号尾巴,例如 运行中 · 3 分钟前 · C:\work · #6250)。

选项 含义
跟随球上显示的会话 发给球现在正显示的那个会话(mirror.focus());一个会话都没有时顺手新建一个。球正在跟的那个会话不会在下面的列表里再出现一次 —— 那两行本来一模一样,只看得出"重复"(见下)
+ 新建会话:工作区名 在那个工作区里新建一个会话(create({ workspaceId })),把这句话发进去;一行一个工作区,明细行写着它的目录,当前会话所在的那个还会标出来。这是下拉里唯一的新建入口:旧版那条不带工作区名的「+ 新建会话」已经移除(见下)
某个具体会话 下拉里按"最近有活动"排列的旧会话(两行文案 + 是否在跑),已经去掉球正在跟的那一个

"球上显示谁"和"这话发给谁"是同一次判断,不会各说各话。发完之后下拉会切到这句话真正 发去的那个会话,面板本身不动(见下)。

为什么"跟随"那一行不再重复出现在列表里

旧版下拉里,球正在跟的那个会话会出现在两个地方:第一条「跟随球上显示的会话:标题」, 以及下面会话列表里的同一行 —— 同一份标题、同一行明细、同一个会话号尾巴,看起来就是列表坏了。 现在只留「跟随」那一行:它已经完整代表那个会话(连明细行都带着),而选中列表里那一条本来也 只会发往同一个会话,所以去掉它没有任何语义损失。选择目标被摘掉时球的按钮会落回「跟随」, 指向的还是那个会话。

为什么"新建会话"要指名工作区

Harness 的 session.create 只接受 workspaceId 或 cwd 之一(两个都给会被 reject), 而它的实现是 cwd = workspace?.path ?? request.cwd ?? defaultCwd —— 只有 workspaceId 那一路会接着调 workspace.attachSession(sessionId)。侧边栏的分组是"登记过的会话 id + 目录 校验"两件事合起来算的,所以只带 cwd 建出来的会话,即使目录正好就是某个工作区的路径, 也不会被算进那个分组,它就挂在「未分组」里(本机实测坐实:同一个 C:\work 目录下, 带 workspaceId 建的会话在 work 分组里,只带 cwd 建的那个在「未分组」)。

所以球上把工作区直接摆进下拉:选了哪个工作区,新会话就登记进哪个工作区,与网页端 "在工作区里新建会话"走的是同一条路。

旧版下拉里还有一条不带工作区名的「+ 新建会话」,已经移除。 它名义上是"目录跟随当前 会话",但当前会话的目录只要没被登记成工作区(workspaceRegistry.list() 里没有它),它就 只能走 { cwd } 那一路 —— 也就是上一条说的情形,新建出来的会话直接掉进「未分组」。既然 "新建会话"只有指名工作区才有归属,下拉里就不该再留第二条路:注册表里一个工作区都没有时, 这一节是空的(球上一条"新建"行都不显示),而不是给一条注定建错地方的路。宿主仍然认 new 这个目标字符串,只是为了一个还在跑旧脚本的球不至于把话发到一个叫 new 的会话上去。

每一行会话是怎么认出来的

以前这里的行只写一个标题,而标题经常还没有(标题是异步落进会话日志的,球拿到的是 会话出生时那份目录快照),于是同一个目录下的四个会话在球上全都叫 work,根本分不清谁是 谁。现在的规则:

  1. 标题(与网页端侧边栏读的同一份 title 投影)—— 三条路都走:落库的 session/title 事件(实时、最准,session/event 一来就知道)、api-session/added 摘要里自带的投影、以及每拍都会补的"按 id 去读一次投影"(O(1) 的折叠结果,不是重放 日志)。只有投影这条路真的空着时,才会按 30 秒一次的节流去问 sessionTitle.get() (那一次会把整份会话日志重放一遍);
  2. 还没有标题就用球自己看到的第一条人写的消息(用户自己的话,最好认;只留一行、只留一次, 球上依旧不显示任何正文与思维链)。只认 source.kind === 'user' 的消息 —— 同一条 user/message 事件也运送 harness 自己注入的通知(改审批策略的 "The approval policy changed …"、运行时上下文快照、模型切换、技能目录……),把它们当会话名字,球上就会出现一行 看着像报错、其实"不是任何会话"的条目(本机实测就是这个 bug)。网页端侧边栏的标题折叠用的是 同一条判据(dsh-session-title 的 sessionTitleUserMessageOf()),球现在跟它一致;
  3. 再退到网页端同一套说法:空白会话是「新会话」,用过的没有标题是「未命名」;
  4. 主文案截断后如果还是会撞车(同名标题、同一目录的多个空会话),给它们统一补上会话号 尾巴(#6250)——宁可稍微吵一点,也不让两行看起来一模一样;
  5. 下面那行永远带着会话号尾巴,它就是"这一行到底是哪个会话"的硬证据(ball.log 里的 发送回执用的是同一串)。

发完不收起

发送之后面板留在原地:输入框清空、面板不收起("说完就走"那套自动收起是早先的设计,已经 移除)。下一句能不能马上接着打,取决于目标会话在不在输出 —— 在输出时那一行是禁用的, 见下一节。结果写在哪:

  • 成败都在页脚那一行(宿主的 setNotice(),15 秒):成功是"已把这句话发进会话。"(插话 则是"已插话到正在跑的这一轮。"),失败是"发送失败:<原因>";
  • 只有成功才把下拉切过去:sendResult 回执现在只剩这一件事(ok + targetId),面板的 形状不再是它的份内事。失败时下拉保持原样,你还能就着刚才选的目标重发一次。

宿主重启前遗留的回执不会让下拉莫名其妙地跳(球只认自己刚发出去的那一条,AwaitingSend)。

发送之后看得见的那一下:输入框会变灰(0.7.0)

页脚那行回执说得再清楚,它也在面板最底下、而且要等一个来回 —— 用户报上来的原话是 "打完字点发送,没有任何反应,不知道发出去没有"。所以这一版多了一条就在你眼皮底下的反馈:

目标会话正在输出的时候,那个输入框与发送按钮是禁用的。 点发送的那一刻它们就变灰了 (球发出动作的同时自己就锁上,不等宿主回话),然后:

什么时候 输入框 发送按钮 输入框上的水印
空闲(目标会话没在跑) 可用 可点,写着「发送」 「在这里输入,回车发给模型;Esc 交还键盘」
刚点完发送、还没等到会话接手 灰 灰,写着「输出中」 「已发出,等会话接手…」
目标会话正在输出 灰 灰,写着「输出中」 「会话正在输出,跑完这一步再发」
这一轮跑完 自己恢复可用 恢复「发送」 回到常态那句

几件需要知道的事:

  • 锁的是"这句话要去哪",不是"面板在跟谁"。「发送到」下拉指着谁就锁谁:跟随球上显示的 会话时锁的就是它;你从下拉里挑了一个正在跑的旧会话,锁的就是那个旧会话;在某个工作区里 "新建会话"永远不锁(那个会话还不存在,没什么在输出)。换一个没在跑的目标,输入框立刻恢复。
  • "正在输出"由宿主给的一份名单决定(状态文档里的 busy,v: 5)。名单的判据是两条取 并集:会话目录说它在跑(api-session/status),或者球自己的状态灯不在空闲/出错。两条 各自都会漏(目录那条是独立广播,状态灯那条只覆盖球看见过事件的会话),并起来才不会"该锁 的时候不锁"。正在等你作答的会话不在名单里 —— 那一刻模型在等人,这个输入框正是这时候 最该能用(球的状态灯也在说"正在向你提问")。
  • 锁住期间不能再插话(steer 那一路恰恰只在模型跑的时候有意义)。想要保留插话就把 lockComposeWhileBusy 关掉:那时宿主根本不发这份名单,"会话在输出 → 锁"这一半整个不成立, 输入框一直可用;只有刚点完发送的那一两秒还会锁一下(球自己那道短锁:发出动作的瞬间就锁, 不等宿主回话,收到回执后缩到 1.5 秒),发送的回执照旧写在页脚。
  • 锁住时键盘立刻交还:禁用的框收不了按键,球不会端着前台守着一个打不了字的框。这一轮 跑完之后想接着说,点一下那个框就行。
  • 换目标是立刻生效的(下拉里点一下,锁就跟着新目标重算);发送失败也立刻解锁,让你能 就着刚才那句重发。

它怎么被验证的(这两个测试都在 npm test 里,都不碰你的桌面):

  • test/compose-lock.mjs 把真正构建出来的 lib/session-ball.js 挂到假的 Cordis Host 上, 用宿主自己写的状态文档当观察窗口,逐条断言名单:目录说在跑、只有 turn/start、turn/end 之后、提问挂着时、答完之后、子代理、lockComposeWhileBusy: false(19 条);
  • test/compose-lock-ball.mjs 把 resources/ball.ps1 原样复制一份,只在 $window.Show() 那一行截断(于是从不出现窗口、没有定时器),末尾接一段替身驱动器:它把一份份现造的状态 文档喂给同一个 Apply-State,再把 IsEnabled / 按钮上的字 / 水印 / 锁的原因写进 ball.log (28 条)。它把"按下发送→当场变灰"、"回执之后仍锁"、"会话跑起来换成「正在输出」"、"跑完 自己解锁"、"换目标立刻解锁"、"失败立刻解锁"、"锁住时不投任何动作"逐条验掉,并且用 RenderTargetBitmap 把面板画成两张 PNG(不 Show 窗口,所以即使这样也不会有任何东西 出现在屏幕上):shot-idle.png 与 shot-busy.png,跑完打印路径。用它们看"变灰到底长什么样" 比读日志快得多 —— 这一版的取舍就是照着这两张图定的:WPF 给禁用按钮刷的是系统那套浅色 (它的模板盖过了本地的 Background),浅色的按钮在这块深色面板上太扎眼,所以锁住时另外 把按钮的不透明度压到 0.45。

走哪条路,以及出错时去哪看

sessionController.prompt 是 @Remote 声明的方法:网关调用它时,会把服务在一个带 invocation 的调用上下文里重新取一次,方法内部的 this.ctx 因此是"这次调用"的上下文; 网页端输入框提交走的正是这条路。Host 插件直接 ctx.get('sessionController').prompt(...) 时,this.ctx 会绑定到插件自己的上下文上 —— 链路上任何依赖调用上下文的东西就会拿到 undefined(本机实测到的报错正是这个形状:Cannot read properties of undefined (reading 'throwIfAborted'),而且那条消息根本没进会话)。所以球上发送优先走 ctx.typertGateway.invoke()(namespace session、method prompt),失败才退回直连: 两条路是同一个业务方法,区别只在调用上下文。新建会话同理(method create)。

一次发送只用一个 requestId:服务端按它去重,所以"第一条路其实已经把话投进去了、只是 最后一步炸了"时,第二条路不会再把同一句话发一遍(它会看到消息已在收件箱里而直接成功)。

两条路的成功与失败都会写进球的 ball.log([host] 球上发送… / 球上新建会话…),失败那条 还带完整栈:球上只能显示一行文案,而那一行不足以定位。

键盘:唯一一处例外,以及它怎么收场

球平常永远不拿键盘焦点(WS_EX_NOACTIVATE),代价是你没法在球上打字。所以点开任何一个 输入框(发送行的框、待办卡片里的"自己写一句"框)的那一刻,球会临时把这个样式去掉、把自己 抬到前台、把光标放进框里 —— 这是唯一会打断"球绝不碰键盘"这条规矩的地方,而它有几条明确的边界:

  • 只在你要用的时候:没有任何后台逻辑会自己去抢键盘;必须是你点了那个输入框。
  • 已知代价:模型正在注入按键时,别在球上打字。 球只在"你点开的输入框"这一段时间里拿键盘, 而 desktop_step 用 SendInput 注入按键、注入的按键送给持有键盘焦点的窗口 —— 正在打字的 球就是那个窗口。所以如果模型跑起 desktop_step(text / key)而球正好在前台:模型敲的字会 落进这个框里(它自己的任务那一下也白做),它那一下回车还会被球当成"发送"。球不检测这种冲突: 它不关心别的工具在做什么,所以既不会拒绝给键盘、也不会把键盘收回(旧版会在"有工具驱动键鼠"时 让开,那段判据已随「避让」一起删除)。这不是"能不能修",而是刻意的边界 —— 悬浮球不建模其它 工具的工作方式。
  • Esc / 点到别的窗口 / 收起面板:都立刻结束输入状态,样式复位,焦点交还给原来那个窗口。 发完一句话不结束、也不收起面板:输入框留着,方便接着说。
  • 说不上话就明说:抬不到前台(Windows 的前台锁有时会拒绝)时不假装能输入,而是在那个 输入框下面提示"这一下没能拿到键盘焦点,请再点一次",并把键盘还回去。
  • 中文输入法:组字期间的回车由输入法自己吃掉(WPF 报的是 ImeProcessed,不是 Return), 不会把半句话发出去。

坏话在前:这一段时间里球确实是前台窗口。如果你把球拖到 / 放在窗口中央,点开输入框时 那个窗口会短暂失去前台位置——这是"要在球上打字"绕不开的代价,也是为什么它只持续到你松开 键盘为止。

提问 / 审批卡片

卡片上有三样东西:选项按钮、"自己写一句"的输入框、在球上作答 / 先收起来。

  • 选项按钮的选中状态只有一个规则:点一下就选中(填色 + ●),再点一下取消(○)。 单选问题点另一个选项就是换过去,被换掉的那个随即回到未选中 —— 视觉永远是从"实际选了什么" 重画出来的,不依赖任何控件模板自己对"checked"的理解(原来的 ToggleButton 一次点击会换两种 颜色,正是"看不出自己的选择到底取消了没有"的来源)。
  • 一张卡片一个选择表:卡片里存的是"这张卡片上每一道题各选了什么",按题号索引。这里踩过 一次:那张表原来是每道题重建一次的,于是多问题的卡片只记得住最后一道题,前面几道 题的选项连"已选中"都画不出来;点「在球上作答」时前面那道题查不到答案,整个点击就静悄悄地 什么也没发生 —— 报告里的"我无法在这样的会话里回答问题"就是这么来的。现在选择表在卡片级 只建一次。
  • 答不全就明说:还有哪道题没选、也没写,点「在球上作答」不会静默失败 —— 那道题下面会 出现"每个问题都要选一项或填一句,再点「在球上作答」"(文案同样来自宿主,脚本保持纯 ASCII)。
  • 卡片里的输入框和发送行一样能打字:它同样走上面那套"临时拿键盘"的机制 —— 一个 WS_EX_NOACTIVATE 窗口里的 TextBox 光靠自己是可以点、可以显示光标、却一个字都收不到的。
  • 先收起来只收起球上的卡片,网页端那张照旧(超时后仍可作答的提问,答案会作为一条补充消息 交给模型)。

球不自己摊开(expandOnPending,默认关)

提问或审批到了的时候,球不会自己把面板弹开。提醒由球自己的表现说:状态灯变色 + 徽标上的数字(pendingCount)—— 看见了、要作答的时候点一下球,面板就开了。

默认之所以是这样:面板自己弹出来会盖住你正在看的东西,而那一下通常并不是你要的(模型问一句 你就少一块屏幕)。要恢复旧行为(一到就摊开),在自己的 Profile patch 里把这一行的 config.expandOnPending 设成 true 即可 —— 那一支只在 user-questions/request / approval/request 到达时调一次 overlay.expand(),其余一切不变:球照样变色、照样挂数字、 照样能作答、网页端照旧同时弹卡片。

它是怎么拿到数据的

只用 Host 既有事件,不新增会话事件、不改任何决策、不写会话日志:

事件 用途
agent/assistant-stream 步骤级状态:现在在思考 / 在写 / 在调工具、token 用量
session/event 提交后的权威信息:工具名与成败、模型名、回合状态、以及会话标题(session/title);顺带留下每个会话第一条人写的消息的一行摘要(标题还没着落时当兜底文案,见上)。工具的参数与结果不再进视图
api-session/added / status / activity / removed 会话目录:「发送到」下拉的候选(标题来自摘要里的 title 投影)、在跑与否、最近活动时间

除此之外还只读地问了三个服务,都是 O(1) 或同步的,不新增任何决策:

服务 用途
ctx.sessionProjections.stateOf(session, 'title') 还没标题的 live 会话按 id 补一次标题(与网页端侧边栏读同一份投影;比 sessionTitle.get() 少一次整日志重放)
ctx.sessionTitle.get(session) 投影这条路空着时的兜底标题;它要重放整份会话日志,所以每个会话最多每 30 秒问一次
ctx.sessions.get(id) 把目录里的会话 id 换成一个 live Session 对象,好读上面那份投影
ctx.workspaceRegistry.list() 「+ 新建会话:工作区名」要列出的工作区(同步、不读持久层),以及"当前会话的目录属于哪个工作区"(没有会话时顺手新建的那一下据此带 workspaceId)
user-questions/request 提问(prepend 挂一层)
approval/request 审批(同上)

它只往会话里写两样东西,都是你亲手点出来的:球上作答(提问 / 审批)与球上发送行发出去的 那句话。前者走 ctx.userQuestions.answer(超时后的补充消息)或同一条 waterfall,后者走 ctx.sessionController.prompt。除此之外它不改任何决策、不写会话日志。

在球上作答是怎么成立的

两个监听器都第一瞬间就 next(),所以网页端的卡片/弹窗照常弹出、一点都不变慢;球上同时 出现同一份内容,哪边先作答就以哪边为准。球被忽略时,一切行为与没有这个插件时完全一致。

  • 提问:带 timeout 的提问在前台等待结束后会变成 continued 状态,这时球上的作答走 ctx.userQuestions.answer(agent, callId, answer),把答案作为一条用户消息送进会话 —— 所以"人不在电脑前、回来才看到球上的问题"也不会白问。球上的卡片会明确显示当前是哪种状态。
  • 审批:没有等价的带外接口,所以球上的批准/拒绝是通过同一个 waterfall 落地的,会抢在 网页端之前生效(allowed-once / rejected)。审批有真实副作用,介意就把 answerApprovals 关掉 —— 那时球只显示、不作答。

球上作答之后,网页端那张卡片要跟着收掉

抢答成功还有一个必须收拾的尾巴:请求既然是在 waterfall 里被球抢先答掉的,那么前台 next() 那条路(api-remotes → 网关 → 网页端)就一直悬着。网页端的卡片并不是"等一个 返回值",而是"等它自己那条请求结束" —— 只要那条请求不结束,卡片就永远挂在页面上:模型 已经拿着球上的答案继续干活了,网页端还在问你同一个问题。

所以作答成功之后会撤销这条请求的投递(runtime.withdraw(),机制见 takeOverCancellation()):AskUserQuestionRequestEvent.signal 的语义本来就是 "Cancellation lifetime of the pending request",网关盯着它,一中止就给客户端推一条 {type:'cancel'},网页端那张卡片随即自己收起。球能在放行之前把 request.signal 换成 "原信号 + 我们自己的中止把手"的复合信号(AbortSignal.any),所以真的取消、轮次中止这些 语义一点没变 —— 变的只是我们多了一个能主动收场的办法。

三条实测记录(本机 web profile):

  1. 请求不总是带 wait.callId(连续两次都是空的),所以球上的作答实际走的是"和网页端比 谁先答"那条路(前台 waterfall),而不是带外 answer()。这条链路已在真实会话里跑通: 在球上点完选项,答案原样成为 ask_user_question 的工具结果,模型接着往下走。
  2. 卡片不会变成"已经答过的幽灵问题":除了上面那次主动撤销(球上先作答的那条路),每次会话 事件到达时还要拿 harness 自己的 userQuestions 投影对账(ctx.sessionProjections.stateOf(session, 'userQuestions'), 与 harness 内部 continued() 读的是同一份数据、同一个 state.questions.active 形状)—— 投影里还开着就保留,顺便把 callId 补回来(第 1 条缺的那个,这是"超时后仍可作答"能成立 的前提);已经不在里面就收起卡片、状态灯回落。不靠猜、不靠计时器。
  3. 本 profile 的 ask_user_question 是 untimed 变体(工具参数里根本没有 timeout),所以 球上不会出倒计时,问题会一直等下去 —— 那是工具本身的行为,不是球少显示了什么。

网页端作答之后,球上这张卡片也要跟着收掉(0.6.3)

上面那条讲的是球上先答:球要负责把网页端那张卡片收掉。反过来同样成立,而反过来这一半 原来根本不存在 —— 这就是用户报上来的那两个 bug:

无法在球上完成沙箱审批;同时在网页界面完成沙箱审批时球上审批不消失。

两句话是同一个根因的两个人眼可见面。审批卡片原来只有一条下场的路:球自己答掉 (finishPending())。别的结局一概只让请求结束、不让卡片走:

  • 网页端先答 → 前台那条投递返回了、approval/decided 也落了库,球上那张卡照旧挂着;
  • 应答者缺席 / 网页端把交互 delegate 掉 → 请求以 unavailable 失败关闭,卡片照旧挂着;
  • 于是球上留下的是僵尸卡:它一直写着"模型在等你批准",而每一次点击都只会得到 "这次审批的前台等待已经结束,只能到网页端处理",卡片纹丝不动 —— 看起来就是"球上没法 完成这次审批"。提问卡片没有这个问题:它还有 session/event(工具结果 + 投影)那条 对账路兜底;审批当时一条都没有。

实测的三处证据(本机,同一次沙箱审批):

来源 内容
会话日志 approval/asked 15:42:42.220 → approval/decided allowed-once 15:42:43.654(网页端答的)
ball.log 15:43:39 / 15:43:40 / 15:43:42 三次 {"kind":"approval",…,"outcome":"allowed-once"} —— 同一张卡连点三次
状态文档 15:49(作答之后 6 分钟)那张卡还在 pending 里,状态灯还是"正在请求审批"

现在收场有三处,前两处是互相独立的判据(与提问卡片的"工具结果 + 投影对账"同一个思路):

  1. Promise 那条路(onApproval()):竞速一结束而"不是球上答的",就把卡片下线 (dropPending()),页脚按结局说清是"网页端已经允许/拒绝"还是"没有可用的应答通道"。 这一处与 0.6.2 的差别只有一行:以前只写页脚、不收卡片。
  2. 会话日志那两张凭据(settleApprovalCard()):approval/asked(身份:toolName + callId)与 approval/decided(同一个 id + 结果)这一对是 harness 自己按成对规则落库的, 权威且耐久 —— 两张到齐就说明这次审批结束了,卡片就走。配对靠 callId(请求到达时球 手上就有,而请求自己没有 id:ApprovalRequestId 是服务落库那一刻才生成的),所以同一会话里 另一张卡不会被误收(测试场景 P 就是钉这一条);配不上号(球是这次审批问到一半才挂载的之类) 时什么都不做,宁可留着也不误收。
  3. 兜底:真有一张卡落到"前台已经结束"的状态再被点,那一击现在是把卡片收掉并明说 "这次审批已经结束了",而不是留在原地说一句无法执行的提示。

outcome 的词汇是封闭的(allowed-once / rejected / cancelled / unavailable), 其中 cancelled(轮次中止、停止生成)走的是 Promise 的 reject 那一支,0.6.2 起就会把 卡片收掉 —— 这条一直是对的,测试场景 B 顺手钉住了它同一边的另一件事:球上先答之后,网页端 那条投递必须被撤掉(request.signal.aborted === true)。

回归测试(不用开球、不碰桌面:把 lib/session-ball.js 挂到一个假的 Host 上,把 LOCALAPPDATA 与 PATH 都指到测试目录,于是没有 PowerShell、没有窗口,只有状态文档):

npm run build && npm test        # test/approval-cards.mjs,19 项

它覆盖:网页端先答(W,修之前是 1 → 1)、球上先答(B)、请求在别处被拒/被中止(R)、凭据只收 自己那张卡(P)、提问卡片没被波及(Q)、以及"Promise 永远不兑现、只有凭据落库"时卡片照样走(A)。

什么时候要重启 Harness

球的脚本(resources/ball.ps1)每次球启动都会从包里重新读一遍(内容哈希命名,改了自然换 文件):改它只要重启球(杀掉球进程,宿主 2 秒内会拉起来)即可生效。

但插件的 JS 模块是另一个世代:运行中的 Harness 会一直用启动时加载的那一份,换掉包里的 lib/session-ball.js 需要重启 dsh web 才会装载(宿主行的 disable/enable 也不会让 Node 的 ESM 缓存失效)。判断当下的实例是哪个世代的最快办法:看状态文档里的 v(当前是 5; 4 = 还没有 busy 的那一代,3 = 还在发 tool 段的更早世代,2 = 只有单行会话文案的更早世代), 以及有没有这些新字段:

  • busy(0.7.0 起):正在输出的会话 id,球上输入框据此决定锁不锁自己(见上「发送之后 看得见的那一下」)。它是一个数组,缺席 = 谁都没在输出;
  • targets.create 是一个数组(一个工作区一条;0.4.1 起不再有不带工作区的那一条),旧世代是一个字符串;
  • targets.sessions[].detail(每行下面那行小字)与 targets.auto.detail;
  • session.text(焦点会话的第一条人写的消息摘要);
  • v: 4 = 没有 tool 段(球上不再显示工具调用内容);有 tool 段就是 3 或更早;
  • sendResult 里没有 note 字段 = 0.6.0 起的那一代(回执只用来切下拉,不再让球收起面板)。

0.6.1 起:球不再自己摊开(DEFAULTS.expandOnPending = false,见上「球不自己摊开」)。 这一代的状态文档形状与 0.6.0 完全一样(v 还是 4,也没有新字段),所以从状态文档看不出 来:要确认当下跑的是哪一代,只能看包里的 package.json 版本,或者直接看行为 —— 提问到达 时球只是变色 + 挂数字、面板不弹,就是 0.6.1 及以后。它同样是宿主模块里的事,换掉 lib/session-ball.js 需要重启 dsh web 才生效(球脚本那一半没变)。

0.6.3:审批卡片在别处结束时也会从球上下来(见上「网页端作答之后,球上这张卡片也要跟着 收掉」)。状态文档形状同样没变(没有新字段),所以也只能靠 package.json 的版本或行为认 (把审批在网页端答掉,卡片当场就没了 = 0.6.3 及以后)。球脚本没变,但宿主模块变了: 换掉 lib/session-ball.js 一样需要重启 dsh web。

0.6.4:删掉「避让」(shrinkWhileDesktop / desktopOpacity / desktopTools 三个配置键, 连同窗口的半透明、鼠标穿透与缩到最小那一套逻辑)。球从此在任何时刻都不改变自己的样子与可点性 (见上「球不让开」)。deskBusy 这个字段在 0.6.4 里留下了,当时它只服务于球脚本的键盘 规则,而且不再被"有没有待办卡片"打折扣(旧版是 deskBusy && pendingCount === 0,那个门是给避让 用的)—— 不过它在 0.6.5 里又整个删掉了,见下。两个 键在状态文档的形状上各有一处变化(window 段少了 clickThrough 与 ballOpacity),但球脚本对 缺席的字段有自己的默认值(不穿透、球体不透明),所以这一代与旧球脚本 / 旧宿主之间两个方向的 错配都无害:唯一的差别是旧宿主 + 新球脚本时,新球脚本不会再穿透、也不会缩到最小(它只认 opacity 这一个窗口字段,而它是个常量)。三个被删的 键在新版里是无效键(Schemastery 会原样保留未知键,resolveSettings 只是不再读它们),所以 你自己的 Profile patch 里如果还写着它们,不会报错,但也没有任何作用 —— 建议删掉。

0.6.5:deskBusy 也删掉 —— 悬浮球不再建模别的工具在做什么。球脚本里那三处判据(点输入框 时拒绝拿键盘并写"输入框先让开"、进键盘模式前拒绝、正在打字时收到这个信号就收回键盘)连同 composeBusy 这句文案、镜像里的 deskBusy 字段与 DESKTOP_TOOLS 判据一起删除。行为上的净变化 只有一条:模型注入按键时球不再让开,代价与边界写在上面「键盘」一节里。状态文档少了 deskBusy 字段(球脚本对缺席字段的办法是"当没有",所以旧宿主 + 新球脚本、新宿主 + 旧球脚本两个方向都不会 坏;唯一的差别是旧球脚本 + 新宿主时它永远等不到那个信号,于是也就永不"让开"—— 正是新版的行为)。 宿主模块与球脚本都变了:lib/session-ball.js 要重启 dsh web,resources/ball.ps1 只要重启球。

0.6.6:两个"看得见"的 bug 一起修,两处都只在球脚本里(所以换掉 resources/ball.ps1 之后 重启球即可;状态文档的形状一个字没变,宿主模块没动)。第一个是展开的一瞬间比现在大: 面板先被 panelHeight(320)撑开、再被量出来的高度盖回去,两个尺寸同一次调用里都露给了合成器 (见上「面板有多高」)。第二个是球一段时间后消失:窗口还在、进程还在,却掉出了置顶带 (WS_EX_TOPMOST 位还在),于是被其它窗口盖住 —— 现在有一条每 1.5 秒的看门狗把它放回去, 并把每次发生写进 ball.log(见上「置顶是"带"不是"位"」)。想知道跑的是哪一代:ball.log 里 ball started 那行的脚本路径是内容哈希命名,新版球的 package.json 是 0.6.6。

0.7.0:发送之后输入框会变灰(见上「发送之后看得见的那一下」)。宿主模块与球脚本都变了 (v: 4 → v: 5,多了 busy 字段与三条新文案):lib/session-ball.js 要重启 dsh web, resources/ball.ps1 只要重启球。

这一代的错配是单边生效、绝不锁死,两个方向都安全,但要知道看到的是什么:

  • 宿主是新的、球脚本是旧的(只重启了 Harness):旧球脚本不认识 busy,所以不会锁, 页脚回执照旧写 —— 行为与 0.6.6 一模一样,只是状态文档里多了一个没人读的字段;
  • 球脚本是新的、宿主还是旧的(只重启了球,就是"改了脚本、还没重启 dsh web"这一段时间): 宿主不发 busy,所以"目标会话在输出"这一半不会触发;但球自己在发出动作的那一刻就 锁上输入框(本地那道锁,最多 5 秒,收到回执后缩到 1.5 秒)—— 于是"点发送→输入框变灰一下" 这一半已经生效了,只是会话跑起来之后不会继续锁着。等重启 dsh web,两半合起来才是完整的 行为(输出期间一直灰、跑完自己恢复)。

球脚本对 create 的这两种形状都认(脚本每次球启动都要重读,宿主模块只在 Harness 重启时换), 所以"脚本已经是新的、宿主还是旧的"这段时间里球不会坏,只是每行只有一行文案。0.6.0 这一代的 两种错配也都无害:宿主是新的、球脚本是旧的 → 球收到回执后仍会自己收起一次(旧行为);反过来 球脚本是新的、宿主是旧的 → 回执里多个没人读的 note,面板照旧不收起。

运行目录里 ball.log 的开头那行会写明这一代球的 debug= 与脚本路径:

2026-10-05 22:19:41.626 [ball 6716] ball started (parent pid 9916, debug=1, compose=66840) script=...\ball.ps1

边界与已知取舍

  • 仅 Windows:球是 Windows PowerShell + WPF 窗口;其它平台插件保持挂载但不启动任何东西。
  • 不做持久化:球是观察窗,宿主重启后从零重建;只有窗口位置/展开状态存在 window.json; 输入框里没发出去的半句话也不跨球重启保存。
  • 子代理不抢焦点:球跟"最近有动静的根会话";某个根会话下子代理在跑时,meta 行会标出来。
  • 球上输入框的锁依赖"它在不在跑"这份判断(见上「发送之后看得见的那一下」):判断来自会话 目录与球自己看到的回合事件,两者都不会因为球被关掉而失灵,但它们错了锁就跟着错 —— 一个被 误判成"还在跑"的会话会让那一行一直灰着。真遇上了先到网页端发消息,或者把 lockComposeWhileBusy 关掉(它是宿主模块的旋钮,改完要重启 dsh web 才生效)。 提问/审批卡片从不受它影响:卡片上的选项与"自己写一句"照旧随时可用,模型在等你作答时 那一行也不会被锁上。
  • 球上只显示状态,不显示内容(见开头):思维链、正文、以及工具调用的参数与结果都不显示。 工具失败时 meta 行会给一句原因(错误码 + 说明),那是"结论"不是"内容";要读完整输出请到网页端。
  • 球上的文字不能选中复制(用的是 TextBlock);要复制请到网页端看。输入框里的内容可以 正常选中、复制、粘贴。
  • 键盘:默认永不抢焦点,唯一例外是"你点开某个输入框"的那一小段时间(Esc、点到别的窗口、 收起面板都立刻交还)。球不检测模型是否在驱动键鼠,所以那段时间里模型注入的按键会落进这个 框里(见上「键盘」)。
  • 面板宽度与高度上限会被虚拟桌面尺寸限幅,窗口位置每次都会夹回可见区域(包括宿主自己 摊开面板的那一次:状态里记的位置是收起小球的位置,直接套到展开的面板上会把右半张卡片 推到屏幕外,那里的按钮就点不到了)。面板按内容变高变矮,所以"内容长到一半被顶到屏幕下沿" 这件事也是当场夹回来的。
  • 「发送到」下拉是自绘的(按钮 + Popup),不用 WPF 的 ComboBox:面板是深色的,而 ComboBox 的 弹出列表按系统主题画,前景/背景会打架。它按 WPF 自己的规矩收场:点按钮开合、点选项就选中并 收起、点到别处由 StaysOpen=False 收起。
  • 每行会话是两行小字,所以下拉列表比"一行一个标题"高一些(最多 300px 后自己滚动)。主文案用 TextTrimming 裁成省略号 —— WPF 的 Button 对字符串内容不裁剪也不省略,长标题原来是被弹出 窗口的边缘硬切掉的,这也是"两行看起来一样"的原因之一。

开发

pnpm install                       # 工作区
pnpm --filter dsh-session-ball-plugin build        # tsdown → lib/
pnpm --filter dsh-session-ball-plugin exec tsc -p tsconfig.typecheck.json
pnpm --filter dsh-session-ball-plugin test         # 三个测试:审批卡片 + 锁的宿主半边 + 锁的球半边
  • src/session-ball.ts:插件入口 —— Config、事件订阅、提问/审批的可作答挂载点、球上发言与 「发送到」目标解析、发送回执、以及状态文档里的 busy 名单
  • src/mirror.ts:事件 → 视图状态的折叠层(纯数据,不碰窗口):状态、会话目录、待办卡片、 标题、busySessions()(现在哪些会话在输出)、以及工具调用里还要用到的那点信息(原始参数 查 timeout、提问调用的生命期)
  • src/overlay.ts:球进程的生命周期与文件协议(state / act / window / 脚本落盘)
  • test/approval-cards.mjs:待办卡片的回归测试。把 lib/ 挂到假 Host 上跑,不启动球、 不碰桌面(LOCALAPPDATA 与 PATH 都指到临时目录里,所以没有 PowerShell、没有窗口), 观察渠道是宿主每 120ms 落盘的状态文档。改审批/提问的生命周期时先跑它。
  • test/compose-lock.mjs:锁的宿主半边(同一套假 Host):busy 名单在"目录说在跑 / 只有 turn/start / turn/end / 提问挂着 / 答完 / 子代理 / lockComposeWhileBusy: false" 这几种情形下各该长什么样,外加"球上那个 prompt 动作真的投给了面板上的焦点会话、并写下回执"。
  • test/compose-lock-ball.mjs:锁的球半边。把 resources/ball.ps1 原样复制一份、只在 $window.Show() 截断(所以从不出现窗口),末尾接一段驱动器:现造的状态文档喂给同一个 Apply-State,再断言 IsEnabled、按钮上的字、水印与锁的原因,最后用 RenderTargetBitmap 画出 shot-idle.png / shot-busy.png 两张面板渲染图(也不 Show 窗口)。改球上那一行的 启用/禁用或文案时跑它。
  • resources/ball.ps1:WPF 窗口本身。必须是纯 ASCII:Windows PowerShell 5.1 在没有 BOM 时 按 ANSI 解码 .ps1,中文会连带把后面的 } 一起吃掉(这个坑踩过一次,后来又踩了一次 —— 改完记得数一下非 ASCII 字节),所以球上的每一句文案都是宿主机在 state JSON 里送进来的。

MIT.

设计取舍:为什么不做成网页里的悬浮球

  • Document Picture-in-Picture(网页里开一个系统级置顶小窗)听起来最省事,但它的内容还是 由页面渲染的,页面失去前台位置时这套东西没有任何优势;而且 Client 半边读不到"正在进行中"的 回合状态(cordis_inspect_query 的 Client Service 目录里没有等价物),只能读提交后的会话 投影 —— 那意味着"它在不在跑、是不是在等我"这些判断得等事件落库,而球要的正是现在。 (球自己也不显示逐字内容,见开头;它显示的是状态。)
  • 页面内的浮层:页面一失去前台位置就看不见了,等于没做。
  • 原生窗口:数据源(Host 事件)是现成的,渲染、置顶、问答卡片也全部可控; 代价是多一个 PowerShell 进程(约 40–80MB),以及上面写清的那两条取舍。