dsh-session-ball-plugin
Verifieddsh-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 之间。三个坑都是实测踩出来的,注释也写在函数上面:
Height="*"那一行在不限高时不要内容的高度(量的时候得临时当 Auto 行);- WPF 会拿缓存的测量结果回答同一个约束(得先把子树整体标脏,见
Reset-Measure); - 标脏只能标到
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 修掉的,症状都是球看起来在右侧和下侧被切了一刀:
- 圆环原来是齐着球缘的(环的直径等于球体,
Margin="1"又被Apply-Shape写的显式 尺寸抵消掉了)。环的外缘与球缘重合时,只要布局把它落在半个像素上,深色球缘就会在某一侧 露到环外面 —— 看起来就是"右下的弧缺了一截、黑球鼓出来一块"。现在环比球体宽 2px, 外缘每边高出球缘 1px,球缘再也露不出来(实测:改前正右/正下最外侧像素是球体色,改后 四个方向最外侧都是环色)。 - 光晕原来是正好填满窗口的(球体 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 是与样子有关的唯一旋钮,而它是常量)。
代价是两条,都需要知情:
- 球会出现在
desktop_step返回的截图里(任何置顶窗口都躲不掉这一点); - 模型如果点到球所在的那几像素上,那一下会落在球上而不是它想点的窗口上。
介意就把球收起来再开始桌面任务。这里曾经有一个 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,根本分不清谁是
谁。现在的规则:
- 标题(与网页端侧边栏读的同一份
title投影)—— 三条路都走:落库的session/title事件(实时、最准,session/event一来就知道)、api-session/added摘要里自带的投影、以及每拍都会补的"按 id 去读一次投影"(O(1) 的折叠结果,不是重放 日志)。只有投影这条路真的空着时,才会按 30 秒一次的节流去问sessionTitle.get()(那一次会把整份会话日志重放一遍); - 还没有标题就用球自己看到的第一条人写的消息(用户自己的话,最好认;只留一行、只留一次,
球上依旧不显示任何正文与思维链)。只认
source.kind === 'user'的消息 —— 同一条user/message事件也运送 harness 自己注入的通知(改审批策略的 "The approval policy changed …"、运行时上下文快照、模型切换、技能目录……),把它们当会话名字,球上就会出现一行 看着像报错、其实"不是任何会话"的条目(本机实测就是这个 bug)。网页端侧边栏的标题折叠用的是 同一条判据(dsh-session-title的sessionTitleUserMessageOf()),球现在跟它一致; - 再退到网页端同一套说法:空白会话是「新会话」,用过的没有标题是「未命名」;
- 主文案截断后如果还是会撞车(同名标题、同一目录的多个空会话),给它们统一补上会话号
尾巴(
#6250)——宁可稍微吵一点,也不让两行看起来一模一样; - 下面那行永远带着会话号尾巴,它就是"这一行到底是哪个会话"的硬证据(
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):
- 请求不总是带
wait.callId(连续两次都是空的),所以球上的作答实际走的是"和网页端比 谁先答"那条路(前台 waterfall),而不是带外answer()。这条链路已在真实会话里跑通: 在球上点完选项,答案原样成为ask_user_question的工具结果,模型接着往下走。 - 卡片不会变成"已经答过的幽灵问题":除了上面那次主动撤销(球上先作答的那条路),每次会话
事件到达时还要拿 harness 自己的
userQuestions投影对账(ctx.sessionProjections.stateOf(session, 'userQuestions'), 与 harness 内部continued()读的是同一份数据、同一个state.questions.active形状)—— 投影里还开着就保留,顺便把callId补回来(第 1 条缺的那个,这是"超时后仍可作答"能成立 的前提);已经不在里面就收起卡片、状态灯回落。不靠猜、不靠计时器。 - 本 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 里,状态灯还是"正在请求审批" |
现在收场有三处,前两处是互相独立的判据(与提问卡片的"工具结果 + 投影对账"同一个思路):
- Promise 那条路(
onApproval()):竞速一结束而"不是球上答的",就把卡片下线 (dropPending()),页脚按结局说清是"网页端已经允许/拒绝"还是"没有可用的应答通道"。 这一处与 0.6.2 的差别只有一行:以前只写页脚、不收卡片。 - 会话日志那两张凭据(
settleApprovalCard()):approval/asked(身份:toolName+callId)与approval/decided(同一个 id + 结果)这一对是 harness 自己按成对规则落库的, 权威且耐久 —— 两张到齐就说明这次审批结束了,卡片就走。配对靠callId(请求到达时球 手上就有,而请求自己没有 id:ApprovalRequestId是服务落库那一刻才生成的),所以同一会话里 另一张卡不会被误收(测试场景 P 就是钉这一条);配不上号(球是这次审批问到一半才挂载的之类) 时什么都不做,宁可留着也不误收。 - 兜底:真有一张卡落到"前台已经结束"的状态再被点,那一击现在是把卡片收掉并明说 "这次审批已经结束了",而不是留在原地说一句无法执行的提示。
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的 ClientService目录里没有等价物),只能读提交后的会话 投影 —— 那意味着"它在不在跑、是不是在等我"这些判断得等事件落库,而球要的正是现在。 (球自己也不显示逐字内容,见开头;它显示的是状态。) - 页面内的浮层:页面一失去前台位置就看不见了,等于没做。
- 原生窗口:数据源(Host 事件)是现成的,渲染、置顶、问答卡片也全部可控; 代价是多一个 PowerShell 进程(约 40–80MB),以及上面写清的那两条取舍。