@hyzyn/dsh-tty 0.22.3 → 0.24.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/lib/index.js CHANGED
@@ -12,18 +12,21 @@ import WebSocket, { WebSocketServer } from 'ws';
12
12
  // found),必须默认导入后取 Terminal;类型用 InstanceType 别名保持同名可用
13
13
  import xtermHeadless from '@xterm/headless';
14
14
  const HeadlessTerminal = xtermHeadless.Terminal;
15
- import { definePlugin, dshHome as resolveDshHome, hasSameOriginProof, isLoopbackRequestStrict, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
15
+ import { definePlugin, dshHome as resolveDshHome, getService, hasSameOriginProof, isLoopbackRequestStrict, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools';
17
17
  import { sanitizeJumpSpec, sanitizeProxyCommand, spawnSsh, sshTarget, expandHome, setCredentialResolver, setProxyCommandPolicy, validateJumpSpec, validateProxyCommand } from './ssh.js';
18
18
  import { sharedGrantStore, auditLoadedGrants, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
19
19
  import { probeSsh } from './probe.js';
20
20
  import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath } from './shell-integration.js';
21
+ import { KEY_VOCABULARY, resolveKeys } from './keys.js';
21
22
  import { parseSshConfigDetailed } from './ssh-config.js';
22
23
  import { parseKnownHostsDetailed } from './known-hosts.js';
23
24
  import { TunnelManager } from './tunnels.js';
24
25
  import { SftpManager } from './sftp.js';
25
26
  import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
26
27
  import { buildRemoteStatsCommand, buildWindowsStatsCommand, hasStatsData, localStatsSampler, parseStatsLine } from './stats.js';
28
+ import { cleanAnsi } from './ansi.js';
29
+ import { buildFailurePrompt, extractCommandFromAnswer, plainAnswerText, shouldExplainExit } from './assist.js';
27
30
  /** SFTP 传输限制默认值。 */
28
31
  const DEFAULT_SFTP_LIMITS = { maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 };
29
32
  /**
@@ -104,6 +107,9 @@ export const Config = z.object({
104
107
  persistence: z.union([z.const('off'), z.const('tmux')]).default('off').volatile(),
105
108
  endOnPageClose: z.boolean().default(false).volatile(),
106
109
  statsEnabled: z.boolean().default(true).volatile(),
110
+ assistEnabled: z.boolean().default(false).volatile(),
111
+ assistProvider: z.string().default('').volatile(),
112
+ assistModel: z.string().default('').volatile(),
107
113
  sftpLimits: z.object({
108
114
  maxDownloadMb: z.natural().max(1024 * 1024).default(1024),
109
115
  maxUploadMb: z.natural().max(1024 * 1024).default(2048),
@@ -229,7 +235,7 @@ const STATS_RETRY_MAX_MS = 300_000;
229
235
  * 调用等得起,而写死的小值会把慢首帧误报成「采不到」。
230
236
  */
231
237
  const STATS_ONESHOT_TIMEOUT_MS = 15_000;
232
- const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎;连接簿条目可配单跳跳板机 ProxyJump),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)用 tty_open 开一个会话跑(或引导用户到终端面板里运行),不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成;它**先回看还没读过的已到达输出**,命令瞬间跑完也不会白等——超时若只返回一句诊断文案,别当成「命令没执行」),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
238
+ const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎;连接簿条目可配单跳跳板机 ProxyJump),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)用 tty_open 开一个会话跑(或引导用户到终端面板里运行),不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target、实时 cwd,以及 `running`——这个会话**有没有命令在跑**,文本里三态写作 `[空闲]` / `[运行中——现在别往里发命令]` / `[命令状态未知]`;命令状态未知**不是**没在跑),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成;它**先回看还没读过的已到达输出**,命令瞬间跑完也不会白等——超时若只返回一句诊断文案,别当成「命令没执行」),tty_send 发送按键(控制键/方向键用具名 `keys`,别在 data 里拼转义序列),tty_run 一次调用跑完一条命令并直接拿回尾部输出+退出码(想省掉 open→等→capture→close 四步时用它),tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
233
239
  /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
234
240
  export function wrapLocalPty(handle) {
235
241
  let resizeWarned = false;
@@ -358,6 +364,11 @@ class LiveConfig {
358
364
  endOnPageClose;
359
365
  /** 服务器状态条:是否采集并推送会话资源指标(默认 true)。 */
360
366
  statsEnabled;
367
+ /** AI 辅助「失败即解释」:默认关(见 Config.assistEnabled)。 */
368
+ assistEnabled;
369
+ /** AI 辅助的模型路由(provider / model 成对;都空 = 跟随宿主默认模型)。 */
370
+ assistProvider;
371
+ assistModel;
361
372
  /** SSH 持久会话名(远程 tmux 托管;本机 socket 清单看不到,随 settings 留存)。 */
362
373
  persistSessions;
363
374
  /** SFTP 传输限制(客户端浏览器侧执行)。 */
@@ -382,6 +393,10 @@ class LiveConfig {
382
393
  // 缺省/旧配置一律视为**关**(这一档是「本机命令执行」,只有显式 true 才开)
383
394
  this.allowProxyCommand = init.allowProxyCommand === true;
384
395
  this.persistSessions = init.persistSessions ?? [];
396
+ // AI 辅助同样「只有显式 true 才开」:它会把终端内容发往模型,缺省必须是关
397
+ this.assistEnabled = init.assistEnabled === true;
398
+ this.assistProvider = typeof init.assistProvider === 'string' ? init.assistProvider.trim() : '';
399
+ this.assistModel = typeof init.assistModel === 'string' ? init.assistModel.trim() : '';
385
400
  }
386
401
  /** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
387
402
  apply(partial) {
@@ -416,6 +431,16 @@ class LiveConfig {
416
431
  this.allowProxyCommand = partial.allowProxyCommand;
417
432
  if (Array.isArray(partial.persistSessions))
418
433
  this.persistSessions = partial.persistSessions;
434
+ if (typeof partial.assistEnabled === 'boolean')
435
+ this.assistEnabled = partial.assistEnabled;
436
+ /*
437
+ * 这两个字段**刻意偏离**本方法的「空串保持原值」惯例:空串是「清掉路由、跟随宿主
438
+ * 默认模型」这个**真实意图**,按惯例处理的话路由一旦填上就再也删不掉(只能重启)。
439
+ */
440
+ if (typeof partial.assistProvider === 'string')
441
+ this.assistProvider = partial.assistProvider.trim();
442
+ if (typeof partial.assistModel === 'string')
443
+ this.assistModel = partial.assistModel.trim();
419
444
  }
420
445
  findSshHost(name) {
421
446
  return this.sshHosts.find((entry) => entry.name === name);
@@ -522,16 +547,9 @@ function tailFromSafeBoundary(text, cap) {
522
547
  * (要完整画面用 tty_screen / xterm-headless 虚拟屏)。
523
548
  */
524
549
  function cleanAnsiTail(raw) {
525
- const withoutOsc = raw.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
526
- const withoutCsi = withoutOsc.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
527
- const withoutEsc = withoutCsi.replace(/\x1b[@-Z\\-_]/g, '');
528
- // 先把「行尾 \r\n」(zsh 行结束常为 \r\r\n)归一成 \n,再按同行覆盖处理
529
- // 剩余孤立的 \r —— 否则回显/输出行会被误判为覆盖而整行抹掉
530
- const normalized = withoutEsc.replace(/\r+\n/g, '\n');
531
- return normalized.split('\n').map((line) => {
532
- const idx = line.lastIndexOf('\r');
533
- return idx === -1 ? line : line.slice(idx + 1);
534
- }).join('\n');
550
+ // 实现已抽到 ./ansi.ts(0.24.0):「失败即解释」的上下文压缩建立在同一套正则上,
551
+ // 各留一份必然漂移。这里保持名字与逐字节相同的行为,调用点一个都不用改。
552
+ return cleanAnsi(raw);
535
553
  }
536
554
  /** OSC 133 命令标记帧:\x1b]133;<A|B|D|T>[;<payload>](BEL|ST)。
537
555
  * T(0.10.0)= tmux 持久标签的 pane 内容快照(base64):tmux 的 pane 重画是
@@ -568,7 +586,7 @@ const FLUSH_INTERVAL_MS = 12;
568
586
  /** 待发输出超过该字符数时跳过窗口立即冲刷(防超长输出无限延迟)。 */
569
587
  const FLUSH_SIZE_CHARS = 64 * 1024;
570
588
  function createShellState() {
571
- return { carry: '', inCommand: false, cmdBuffer: '', pendingT: null, lastCommand: null };
589
+ return { carry: '', sawMark: false, inCommand: false, cmdBuffer: '', pendingT: null, lastCommand: null, cmdSeq: 0 };
572
590
  }
573
591
  /** OSC 133;T 的 base64 payload → utf8 文本(无效输入返回 null)。 */
574
592
  function decodeBase64Utf8(payload) {
@@ -623,6 +641,7 @@ export function feedShellIntegration(session, text) {
623
641
  OSC133_RE.lastIndex = 0;
624
642
  let cursor = 0;
625
643
  for (const match of data.matchAll(OSC133_RE)) {
644
+ state.sawMark = true; // 命令边界从此可信(A/B/D/T 任意一个都算)
626
645
  const segment = data.slice(cursor, match.index).replace(OSC7_RE, '');
627
646
  if (state.inCommand && segment !== '') {
628
647
  state.cmdBuffer = (state.cmdBuffer + segment).slice(-COMMAND_CAP);
@@ -639,10 +658,12 @@ export function feedShellIntegration(session, text) {
639
658
  else if (kind === 'D') {
640
659
  if (state.inCommand) {
641
660
  const exitCode = match[2] !== undefined && /^\d+$/.test(match[2]) ? Number(match[2]) : null;
661
+ state.cmdSeq += 1;
642
662
  state.lastCommand = {
643
663
  output: (state.pendingT ?? state.cmdBuffer).slice(-COMMAND_CAP),
644
664
  exitCode: exitCode !== null && Number.isFinite(exitCode) ? exitCode : null,
645
665
  endedAt: Date.now(),
666
+ seq: state.cmdSeq,
646
667
  };
647
668
  state.inCommand = false;
648
669
  state.cmdBuffer = '';
@@ -658,6 +679,287 @@ export function feedShellIntegration(session, text) {
658
679
  state.cmdBuffer = (state.cmdBuffer + rest).slice(-COMMAND_CAP);
659
680
  }
660
681
  }
682
+ /**
683
+ * 「这个会话有没有命令在跑」的判据(0.23.0,tty_list 的 `running`)。
684
+ *
685
+ * 三态是刻意的:`running` **省略**表示这个会话根本没有可信的命令边界(没见过
686
+ * OSC 133 标记),而**不是**「没在跑」。把未知报成 false 会让 agent 往一个正在
687
+ * 跑的程序里塞命令——往 vim / apt / less 的交互提示里打字,那些字节被当输入吃掉,
688
+ * 是要等下一次 expect 超时才发现的静默事故。
689
+ *
690
+ * 两条不依赖 shell 集成的确定性判据:
691
+ * - 进程已退出 → 没在跑(false);
692
+ * - **命令型会话**(`tty_open command=` / `tty_run` / SSH 的 exec 标签)的进程
693
+ * **就是**那条命令 → 活着就等于在跑(true)。这里刻意不看 inCommand:命令型
694
+ * 会话不注入 shell 集成钩子,D 标记永远不会来。
695
+ */
696
+ function runningOf(session) {
697
+ const state = session.shellState;
698
+ const last = state.lastCommand;
699
+ // 「上一条已完成命令」只在标记可信时说(否则 lastCommand 永远是 null,
700
+ // 本来也不会进这条分支;sawMark 是给「标记中途失效」留的守卫)
701
+ const lastInfo = last === null || !state.sawMark
702
+ ? {}
703
+ : {
704
+ ...(last.exitCode === null ? {} : { lastExitCode: last.exitCode }),
705
+ lastExitAt: last.endedAt,
706
+ };
707
+ if (session.exited !== null)
708
+ return { running: false, ...lastInfo };
709
+ if (session.commandSession)
710
+ return { running: true, ...lastInfo };
711
+ if (!state.sawMark)
712
+ return {};
713
+ return { running: state.inCommand, ...lastInfo };
714
+ }
715
+ /**
716
+ * 等一个命令型会话的进程结束(tty_run 用;超时返回 false,不抛错、不杀会话)。
717
+ *
718
+ * 为什么用 `handle.done` 而不是轮询 `session.exited`:`watchDone` 在同一 promise 上
719
+ * **先**注册(spawn 时就挂上了),promise 回调按注册顺序跑,所以本函数的 then 一定
720
+ * 排在 `finishSession` 之后——回来时缓冲已 force 冲刷(D76 的终局尾巴)、
721
+ * `session.exited` 已就位。命令瞬间跑完也不会白等:done 早已 resolve,then 立刻跑。
722
+ */
723
+ async function waitForSessionExit(session, timeoutMs) {
724
+ if (session.exited !== null)
725
+ return true;
726
+ let timer = null;
727
+ try {
728
+ return await Promise.race([
729
+ session.handle.done.then(() => true, () => true),
730
+ new Promise((resolve) => {
731
+ timer = setTimeout(() => { resolve(false); }, timeoutMs);
732
+ }),
733
+ ]);
734
+ }
735
+ finally {
736
+ if (timer !== null)
737
+ clearTimeout(timer);
738
+ }
739
+ }
740
+ /** 一次解释请求的超时(毫秒)。用户正盯着屏幕等,30s 是「还能忍」的上限。 */
741
+ const ASSIST_TIMEOUT_MS = 30_000;
742
+ /**
743
+ * 解释请求的 maxTokens。
744
+ *
745
+ * 取 4096 是 rss 的**真机实测拐点**(见 packages/rss/src/index.ts 的长注释):推理模型
746
+ * 与最终答案**共享**这个预算,200 / 1024 都会让一部分请求死在 `max-tokens` 上,4096
747
+ * 才既容得下推理开销又不至于把整轮拖过超时。
748
+ */
749
+ const ASSIST_MAX_TOKENS = 4096;
750
+ /**
751
+ * 取模型候选的超时(毫秒)。
752
+ *
753
+ * 比一次解释请求短得多是**故意**的:候选只是输入框旁边的建议,等不到就该让用户直接手输;
754
+ * 而远端目录一慢(有的适配器要去问服务端点)会把整张设置卡片吊住,那种「打开设置像卡死」
755
+ * 比「没有候选」糟得多。
756
+ */
757
+ const MODEL_CATALOG_TIMEOUT_MS = 6_000;
758
+ /**
759
+ * 解析模型路由:显式配置对 > 宿主默认模型(服务 agentDefaultModel,兼容 currentSelection)
760
+ * > settings 的 agent-default-model 命名空间;全拿不到返回 error(**不是** null —— 界面要
761
+ * 一句能照做的原因,而不是一个「点了没反应」的徽标)。
762
+ *
763
+ * 与 rss 的 resolveAiRoute 同构,但多一条**成对规则**的显式报错:只填一个时既不生效、
764
+ * 也不该静默回落到宿主默认(用户以为自己配了,实际走的是别的模型)。
765
+ */
766
+ export function resolveAssistRoute(ctx, provider, model) {
767
+ const p = provider.trim();
768
+ const m = model.trim();
769
+ if (p !== '' && m !== '')
770
+ return { route: { provider: p, model: m } };
771
+ if (p !== '' || m !== '') {
772
+ return { error: 'provider 与 model 需要成对填写(只填一个不生效):补齐另一个,或两个都清空以跟随宿主默认模型' };
773
+ }
774
+ try {
775
+ const defaultModel = getService(ctx, 'agentDefaultModel');
776
+ const read = typeof defaultModel?.source === 'function'
777
+ ? defaultModel.source
778
+ : typeof defaultModel?.currentSelection === 'function'
779
+ ? defaultModel.currentSelection
780
+ : undefined;
781
+ if (read !== undefined) {
782
+ const picked = pickRoute(read.call(defaultModel));
783
+ if (picked !== null)
784
+ return { route: picked };
785
+ }
786
+ const settings = getService(ctx, 'settings');
787
+ if (typeof settings?.get === 'function') {
788
+ const picked = pickRoute(settings.get('agent-default-model'));
789
+ if (picked !== null)
790
+ return { route: picked };
791
+ }
792
+ }
793
+ catch {
794
+ /* 服务异常 / 命名空间未注册(get 抛 TypeError):当作解析不到,如实回报 */
795
+ }
796
+ return { error: '没有可用的模型路由:在卡片里填 provider / model,或先给宿主配一个默认模型' };
797
+ }
798
+ /**
799
+ * 一次性调用宿主 llm 服务:收集 text-delta 直到 finish(导出仅供单测,照 rss 的
800
+ * callAiSummary 先例——只测假件的话,「忘了在路由里调用它」这种回归一条都拦不住,
801
+ * 而 finish 块的形状错误又恰好是 rss 踩过的坑)。
802
+ */
803
+ export async function askModelOnce(llm, route, prompt, timeoutMs = ASSIST_TIMEOUT_MS) {
804
+ const controller = new AbortController();
805
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
806
+ timer.unref?.();
807
+ let text = '';
808
+ let finishKind;
809
+ let finishMessage;
810
+ try {
811
+ for await (const chunk of llm.stream({
812
+ provider: route.provider,
813
+ model: route.model,
814
+ system: prompt.system,
815
+ // content 必须是 ContentBlock[](字符串会在下游 .some(...) 上炸)
816
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt.user }] }],
817
+ maxTokens: ASSIST_MAX_TOKENS,
818
+ temperature: 0.2,
819
+ signal: controller.signal,
820
+ })) {
821
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
822
+ text += chunk.text;
823
+ }
824
+ else if (chunk.type === 'finish') {
825
+ // kind / failure 在 reason **里面**(顶层读它们会把每次成功都判成 unknown)
826
+ const reason = chunk.reason;
827
+ finishKind = typeof reason?.kind === 'string' ? reason.kind : 'unknown';
828
+ finishMessage = reason?.failure?.message;
829
+ }
830
+ }
831
+ }
832
+ catch (error) {
833
+ // 主动 abort 视作超时;其余错误原样抛出(provider 报的错比「请求失败」有用得多)
834
+ if (controller.signal.aborted)
835
+ throw new Error('请求超时(' + String(timeoutMs) + 'ms)');
836
+ throw error instanceof Error ? error : new Error(String(error));
837
+ }
838
+ finally {
839
+ clearTimeout(timer);
840
+ }
841
+ if (finishKind === undefined)
842
+ throw new Error('模型未返回终止标记');
843
+ if (finishKind !== 'stop') {
844
+ if (finishKind === 'max-tokens') {
845
+ // 最容易被误读成「provider 坏了」的一档:把「该调什么」直接写进原因里
846
+ throw new Error('输出被 maxTokens=' + String(ASSIST_MAX_TOKENS) + ' 截断(推理模型会先消耗推理 token):换一个更轻的模型,或把上面的路由指向非推理模型');
847
+ }
848
+ throw new Error(finishMessage !== undefined ? finishKind + ': ' + finishMessage : '终止原因 ' + finishKind);
849
+ }
850
+ const answer = text.trim();
851
+ if (answer === '')
852
+ throw new Error('模型返回了空答案');
853
+ return answer;
854
+ }
855
+ /** 从任意值里取 provider/model 对;不完整返回 null(与 rss 的 pickRoute 同构)。 */
856
+ function pickRoute(value) {
857
+ if (typeof value !== 'object' || value === null)
858
+ return null;
859
+ const record = value;
860
+ const provider = typeof record.provider === 'string' ? record.provider.trim() : '';
861
+ const model = typeof record.model === 'string' ? record.model.trim() : '';
862
+ return provider !== '' && model !== '' ? { provider, model } : null;
863
+ }
864
+ /**
865
+ * 模型候选目录(设置卡片 provider / model 两栏的候选列表)。
866
+ *
867
+ * ⚠️ 目录是**建议**、不是白名单:dsh-llm 明写「核心路由接受未列出的 model id」,而
868
+ * 「基础空目录」是**合法**状态(那种适配器压根不提供 GUI 选择)。所以下面每一条失败路径
869
+ * 都收敛成**空候选**——卡片里的输入框照旧可以手输,绝不因为「取不到候选」把人锁死。
870
+ */
871
+ /** 把目录项归一成 { id, name }:丢掉没有 id 的、按 id 去重(远端目录会重复)。 */
872
+ function normalizeCatalogEntries(raw) {
873
+ if (!Array.isArray(raw))
874
+ return [];
875
+ const out = [];
876
+ const seen = new Set();
877
+ for (const item of raw) {
878
+ if (typeof item !== 'object' || item === null)
879
+ continue;
880
+ const record = item;
881
+ const id = typeof record.id === 'string' ? record.id.trim() : '';
882
+ if (id === '' || seen.has(id))
883
+ continue;
884
+ seen.add(id);
885
+ const name = typeof record.name === 'string' && record.name.trim() !== '' ? record.name.trim() : id;
886
+ out.push({ id, name });
887
+ }
888
+ return out;
889
+ }
890
+ /** 已注册的 provider 路由;服务没有这个方法、或抛错,都只当「没有候选」。导出仅供单测。 */
891
+ export function listProvidersOf(llm) {
892
+ try {
893
+ const fn = llm?.listProviders;
894
+ if (typeof fn !== 'function')
895
+ return [];
896
+ return normalizeCatalogEntries(fn.call(llm));
897
+ }
898
+ catch {
899
+ return [];
900
+ }
901
+ }
902
+ /**
903
+ * 某个 provider 广告的模型。
904
+ *
905
+ * **必须有超时**:适配器是拿远端目录喂这个方法的(pi-ai 那一族会去问服务端点),网络一慢
906
+ * 就会把设置卡片吊住——而候选只是「建议」,等不到就该立刻放弃、让用户直接手输。
907
+ */
908
+ export async function listModelsOf(llm, provider, timeoutMs = MODEL_CATALOG_TIMEOUT_MS) {
909
+ try {
910
+ const fn = llm?.listModels;
911
+ if (typeof fn !== 'function')
912
+ return [];
913
+ const pending = Promise.resolve(fn.call(llm, provider));
914
+ return normalizeCatalogEntries(await withSoftTimeout(pending, timeoutMs));
915
+ }
916
+ catch {
917
+ return [];
918
+ }
919
+ }
920
+ /**
921
+ * 每个 provider 的模型**并行**取回来,给「一个控件同时选渠道 + 模型」的候选表用。
922
+ *
923
+ * 判据同 listModelsOf:拿不到就是空数组——**一个 provider 坏掉不许把整张候选表清空**,
924
+ * 用户至少还能从别的渠道里选。并行 + 每个自带软超时,所以总时长仍被一次超时界住。
925
+ */
926
+ export async function listGroupsOf(llm, providers) {
927
+ return await Promise.all(providers.map(async (item) => ({
928
+ id: item.id,
929
+ name: item.name,
930
+ models: await listModelsOf(llm, item.id),
931
+ })));
932
+ }
933
+ /** 到点就放弃(原 promise 继续跑,结果丢弃):只给「建议」类查询用,绝不让 UI 等网络。 */
934
+ function withSoftTimeout(promise, ms) {
935
+ return new Promise((resolve, reject) => {
936
+ const timer = setTimeout(() => reject(new Error('catalog timeout')), ms);
937
+ timer.unref?.();
938
+ void promise.then((value) => {
939
+ clearTimeout(timer);
940
+ resolve(value);
941
+ }, (error) => {
942
+ clearTimeout(timer);
943
+ reject(error instanceof Error ? error : new Error(String(error)));
944
+ });
945
+ });
946
+ }
947
+ /**
948
+ * 虚拟屏的可见文本:每行去尾空格、去掉末尾空行(屏幕末尾的提示符行才是有效区)。
949
+ *
950
+ * 与 `tty_screen` 工具共用同一份实现——「屏幕上是什么」只能有一个答案,两处各写一份
951
+ * 迟早会漂(一处按 rows 遍历、一处按 buffer 长度遍历,就差出去了)。
952
+ */
953
+ function screenTextOf(screen) {
954
+ const buffer = screen.buffer.active;
955
+ const lines = [];
956
+ for (let row = 0; row < screen.rows; row++) {
957
+ lines.push(buffer.getLine(row)?.translateToString(true) ?? '');
958
+ }
959
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
960
+ lines.pop();
961
+ return lines.join('\n');
962
+ }
661
963
  /**
662
964
  * 唯一追加点(D72):环形缓冲与单调字符计数一起维护。水位线用绝对值定位
663
965
  * 未读区(`buffer 起点 = outputSeq - buffer.length`,裁剪只会前移起点,
@@ -1369,6 +1671,14 @@ export class SessionManager {
1369
1671
  // 省略 = 不按时间释放(策略 ∞):不能塞 Infinity,理由见 retainLeftMs
1370
1672
  ...(exitRetainMs === null ? {} : { retainMs: exitRetainMs }),
1371
1673
  };
1674
+ // 命令边界三态(0.23.0):省略的键必须**不出现**(`?? undefined` 会留下一个
1675
+ // undefined 键,宿主输出校验判「不是无损 JSON」(D52/B33))
1676
+ const runInfo = runningOf(session);
1677
+ const runFields = {
1678
+ ...(runInfo.running === undefined ? {} : { running: runInfo.running }),
1679
+ ...(runInfo.lastExitCode === undefined ? {} : { lastExitCode: runInfo.lastExitCode }),
1680
+ ...(runInfo.lastExitAt === undefined ? {} : { lastExitAt: runInfo.lastExitAt }),
1681
+ };
1372
1682
  const base = {
1373
1683
  sid: session.id,
1374
1684
  cwd: session.cwd,
@@ -1377,6 +1687,7 @@ export class SessionManager {
1377
1687
  startedAt: session.startedAt,
1378
1688
  lastOutputAt: session.lastOutputAt,
1379
1689
  owner: session.owner,
1690
+ ...runFields,
1380
1691
  ...(session.tmuxName !== null ? { persist: true } : {}),
1381
1692
  ...exitInfo,
1382
1693
  };
@@ -2125,12 +2436,14 @@ export class TtyServer {
2125
2436
  cwd,
2126
2437
  kind: 'local',
2127
2438
  target: '',
2439
+ commandSession: command !== null,
2128
2440
  startedAt: Date.now(),
2129
2441
  lastOutputAt: Date.now(),
2130
2442
  lastInputAt: Date.now(),
2131
2443
  outputSeq: 0,
2132
2444
  readSeq: -1,
2133
2445
  readMarkAt: 0,
2446
+ assistHintedSeq: -1,
2134
2447
  recentInputs: [],
2135
2448
  buffer: '',
2136
2449
  decoder: new StringDecoder('utf8'),
@@ -2415,12 +2728,14 @@ export class TtyServer {
2415
2728
  cwd: '',
2416
2729
  kind: 'ssh',
2417
2730
  target,
2731
+ commandSession: command !== null,
2418
2732
  startedAt: Date.now(),
2419
2733
  lastOutputAt: Date.now(),
2420
2734
  lastInputAt: Date.now(),
2421
2735
  outputSeq: 0,
2422
2736
  readSeq: -1,
2423
2737
  readMarkAt: 0,
2738
+ assistHintedSeq: -1,
2424
2739
  recentInputs: [],
2425
2740
  buffer: '',
2426
2741
  decoder: new StringDecoder('utf8'),
@@ -2684,6 +2999,35 @@ export class TtyServer {
2684
2999
  // 面板即时看到保留态(第二个窗口据此不建幽灵标签;agent 标签由 exit 帧标记)
2685
3000
  this.broadcastSessions();
2686
3001
  }
3002
+ /**
3003
+ * 「上一条命令失败了」的徽标帧(0.24.0)。
3004
+ *
3005
+ * 只能在**输出下行路径**里判:shell 集成的 `D` 标记可能落在任意一块数据里,没有
3006
+ * 「命令结束」的独立事件可挂。判据全在 assist.ts 的 `shouldExplainExit`(0 不弹、
3007
+ * 130 / 141 豁免)。
3008
+ *
3009
+ * `assistHintedSeq` 按 `lastCommand.seq`(单调序号)去重:同一条命令只弹一次——否则用户
3010
+ * 关掉徽标之后,只要终端再吐一个字节(比如敲了下一个字符的回显)它就会重新冒出来。
3011
+ *
3012
+ * 没有连接时直接返回:徽标是**实时提示**,不是待办队列;等重连时补发会让一个刚打开
3013
+ * 的标签莫名其妙地顶着一个旧徽标。
3014
+ */
3015
+ maybeEmitAssistHint(session) {
3016
+ if (!this.options.assistEnabled)
3017
+ return;
3018
+ const state = session.shellState;
3019
+ const last = state.lastCommand;
3020
+ if (last === null || last.seq === session.assistHintedSeq)
3021
+ return;
3022
+ session.assistHintedSeq = last.seq;
3023
+ if (session.exited !== null || session.clients.size === 0)
3024
+ return;
3025
+ if (!shouldExplainExit(last.exitCode))
3026
+ return;
3027
+ for (const client of session.clients.values()) {
3028
+ send(client.ws, { t: 'hint', sid: client.sid, kind: 'failure', exitCode: last.exitCode, at: last.endedAt });
3029
+ }
3030
+ }
2687
3031
  /** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
2688
3032
  attachOutput(session) {
2689
3033
  const output = session.handle.output;
@@ -2720,6 +3064,7 @@ export class TtyServer {
2720
3064
  session.lastOutputAt = Date.now();
2721
3065
  appendOutput(session, text);
2722
3066
  feedShellIntegration(session, text);
3067
+ this.maybeEmitAssistHint(session);
2723
3068
  const screen = session.screen;
2724
3069
  if (screen !== null) {
2725
3070
  // 心跳包裹(D57):同步抛出 / 解析停摆的屏会被退役,而不是让 tty_screen 一直返回冻结画面
@@ -3180,6 +3525,15 @@ const plugin = definePlugin({
3180
3525
  sftpLimits: sanitizeSftpLimits(config?.sftpLimits),
3181
3526
  allowProxyCommand: config?.allowProxyCommand === true,
3182
3527
  persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
3528
+ /*
3529
+ * tty D93 = D91 的**第三个实例**(同一根因:新字段要记得回来补一行)。这处比 applyPatch 那边更隐蔽:
3530
+ * applyPatch 是**热更新**路径,这里是**启动**路径——配置文件里写着 assistEnabled: true
3531
+ * 却不生效,只有「settings 里存过东西」的那条路能把它救回来。症状仍是本仓最忌讳的
3532
+ * 「配了没反应」(卡片/配置说开着,宿主里 live 还是 false)。
3533
+ */
3534
+ assistEnabled: config?.assistEnabled === true,
3535
+ assistProvider: typeof config?.assistProvider === 'string' ? config.assistProvider : '',
3536
+ assistModel: typeof config?.assistModel === 'string' ? config.assistModel : '',
3183
3537
  });
3184
3538
  /*
3185
3539
  * 闸门在**挂载时先初始化一次**(tty D70)。
@@ -3252,6 +3606,9 @@ const plugin = definePlugin({
3252
3606
  persistence: live.persistence,
3253
3607
  endOnPageClose: live.endOnPageClose,
3254
3608
  statsEnabled: live.statsEnabled,
3609
+ assistEnabled: live.assistEnabled,
3610
+ assistProvider: live.assistProvider,
3611
+ assistModel: live.assistModel,
3255
3612
  sftpLimits: live.sftpLimits,
3256
3613
  allowProxyCommand: live.allowProxyCommand,
3257
3614
  allowProxyCommandGranted: capabilityGranted(CAP_PROXY_COMMAND),
@@ -3275,21 +3632,22 @@ const plugin = definePlugin({
3275
3632
  /** 规范化并应用一份配置补丁(volatile 更新事件与 HTTP POST 共用;幂等)。 */
3276
3633
  const applyPatch = (section) => {
3277
3634
  live.apply({
3278
- shell: typeof section.shell === 'string' ? section.shell : undefined,
3279
- term: typeof section.term === 'string' ? section.term : undefined,
3280
- colorTerm: typeof section.colorTerm === 'string' ? section.colorTerm : undefined,
3281
- cwd: typeof section.cwd === 'string' ? section.cwd : undefined,
3282
- reconnectGraceSec: typeof section.reconnectGraceSec === 'number' ? section.reconnectGraceSec : undefined,
3635
+ /*
3636
+ * **先把整份补丁铺开**,再逐个覆盖需要清洗/归一化的字段。
3637
+ *
3638
+ * D91:这里原先是一张**逐字段的显式清单**,于是新加的 volatile 字段必须记得回来补一行
3639
+ * ——`assistEnabled` 就是这么漏的:profile 里存下来了、`live` 却没变,`snapshot()` 回给
3640
+ * 卡片的还是旧值,表现就是「点了保存,勾又弹回去」。铺开之后**新字段默认就会热生效**;
3641
+ * `live.apply` 对每个字段都有类型守卫,多出来的键自然被忽略(类型上收窄成它的参数类型)。
3642
+ * 只有**需要清洗**的字段才留在这张清单里。
3643
+ */
3644
+ ...section,
3283
3645
  sshHosts: sanitizeSshHosts(section.sshHosts),
3284
3646
  hostKeys: sanitizeHostKeys(section.hostKeys),
3285
- shellIntegration: typeof section.shellIntegration === 'boolean' ? section.shellIntegration : undefined,
3286
3647
  tunnels: sanitizeTunnels(section.tunnels),
3287
- persistence: section.persistence === 'tmux' || section.persistence === 'off' ? section.persistence : undefined,
3288
- endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
3289
- statsEnabled: typeof section.statsEnabled === 'boolean' ? section.statsEnabled : undefined,
3290
- sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
3291
- allowProxyCommand: typeof section.allowProxyCommand === 'boolean' ? section.allowProxyCommand : undefined,
3292
3648
  persistSessions: sanitizePersistSessions(section.persistSessions),
3649
+ // sftpLimits 必须留在清单里:apply 用展开运算合并它,传进非对象会把字符索引并进去
3650
+ sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
3293
3651
  });
3294
3652
  /*
3295
3653
  * ProxyCommand 闸门跟着 settings 走(**每次热应用都写一次**):这一档是「设置字段驱动的
@@ -3323,7 +3681,7 @@ const plugin = definePlugin({
3323
3681
  /** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
3324
3682
  const normalizePatch = (input) => {
3325
3683
  const patch = {};
3326
- const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'statsEnabled', 'sftpLimits', 'allowProxyCommand']);
3684
+ const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'statsEnabled', 'sftpLimits', 'allowProxyCommand', 'assistEnabled', 'assistProvider', 'assistModel']);
3327
3685
  for (const key of Object.keys(input)) {
3328
3686
  if (!known.has(key))
3329
3687
  return { error: '未知配置项: ' + key };
@@ -3375,6 +3733,24 @@ const plugin = definePlugin({
3375
3733
  return { error: 'statsEnabled 必须是布尔值' };
3376
3734
  patch.statsEnabled = input.statsEnabled;
3377
3735
  }
3736
+ if (input.assistEnabled !== undefined) {
3737
+ if (typeof input.assistEnabled !== 'boolean')
3738
+ return { error: 'assistEnabled 必须是布尔值' };
3739
+ patch.assistEnabled = input.assistEnabled;
3740
+ }
3741
+ /*
3742
+ * provider / model 刻意**允许空串**(与下方 shell/term/colorTerm 的「空串 = 不修改」
3743
+ * 不同):空串是「跟随宿主默认模型」这个真实意图。也刻意**不在这里**校验成对——
3744
+ * 卡片是逐字段保存的,先填 provider 再填 model 必然经过一次「只填了一个」的中间态,
3745
+ * 在那里驳回会让用户根本填不完;成对规则留到真正解析路由时判(resolveAssistRoute)。
3746
+ */
3747
+ for (const key of ['assistProvider', 'assistModel']) {
3748
+ if (input[key] === undefined)
3749
+ continue;
3750
+ if (typeof input[key] !== 'string')
3751
+ return { error: key + ' 必须是字符串' };
3752
+ patch[key] = input[key].trim();
3753
+ }
3378
3754
  if (input.allowProxyCommand !== undefined) {
3379
3755
  if (typeof input.allowProxyCommand !== 'boolean')
3380
3756
  return { error: 'allowProxyCommand 必须是布尔值' };
@@ -3550,6 +3926,144 @@ const plugin = definePlugin({
3550
3926
  writeJson(res, 200, { ok: true, config: snapshot() });
3551
3927
  },
3552
3928
  }));
3929
+ /*
3930
+ * AI 辅助「失败即解释」(0.24.0)。
3931
+ *
3932
+ * 与 /config 同档:**不进** MUTATION_SUBROUTES——它不改宿主状态;真正的闸是设置里
3933
+ * 那个**默认关闭**的开关(assistEnabled),回环围栏照常过。
3934
+ *
3935
+ * 刻意**不做流式**:本插件没有任何 SSE 基建,为一段几百 token 的回答新开一条流
3936
+ * 不划算;一次 POST 拿整段 + 客户端「取消」就够。模型的思考过程对用户也没有价值。
3937
+ */
3938
+ /*
3939
+ * 同一会话的**在途**询问(0.24.0,tty D94)。客户端已经拦了重复点击,但两个标签页、或客户端
3940
+ * 竞态仍可能同时打进来,而每一次都是**真花一次模型调用**。这里按 sid 记在途,
3941
+ * 重复的直接 409 挡回去;finally 里一定清,否则一次异常就把会话锁死。
3942
+ */
3943
+ const assistInFlight = new Set();
3944
+ disposers.push(webServer.register({
3945
+ kind: 'exact',
3946
+ path: '/api/dsh-tty/assist',
3947
+ handler: async (req, res) => {
3948
+ if (!(await gateRoute(req, res))) {
3949
+ return;
3950
+ }
3951
+ if (req.method !== 'POST') {
3952
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
3953
+ return;
3954
+ }
3955
+ /*
3956
+ * 开关是**宿主侧**的闸,不靠客户端自觉:关掉时客户端连徽标都收不到,但配置
3957
+ * 随时可被改,而「关掉必须立刻生效」是这类外发开关的底线。
3958
+ */
3959
+ if (!live.assistEnabled) {
3960
+ writeJson(res, 403, { error: 'AI 辅助未开启(插件配置 → 终端面板 → 「失败即解释」)' });
3961
+ return;
3962
+ }
3963
+ const body = await readJsonBody(req);
3964
+ if (body === undefined) {
3965
+ writeJson(res, 400, { error: 'invalid JSON body' });
3966
+ return;
3967
+ }
3968
+ const sid = typeof body.sid === 'string' ? body.sid : '';
3969
+ const session = sessions.get(sid);
3970
+ if (sid === '' || session === undefined || session.closed) {
3971
+ writeJson(res, 404, { error: '会话不存在或已退出: ' + sid });
3972
+ return;
3973
+ }
3974
+ const resolved = resolveAssistRoute(ctx, live.assistProvider, live.assistModel);
3975
+ if (resolved.route === undefined) {
3976
+ writeJson(res, 409, { error: resolved.error ?? '没有可用的模型路由' });
3977
+ return;
3978
+ }
3979
+ const llm = getService(ctx, 'llm');
3980
+ if (llm === undefined || llm === null || typeof llm.stream !== 'function') {
3981
+ writeJson(res, 409, { error: '宿主 llm 服务不可用(当前宿主没有提供模型调用)' });
3982
+ return;
3983
+ }
3984
+ /*
3985
+ * 进到这行才说明这次真的要花一次模型调用了,所以在**这里**记账(而不是上面更早处):
3986
+ * 重复请求挡在门外、且不占用一次调用。
3987
+ */
3988
+ if (assistInFlight.has(sid)) {
3989
+ writeJson(res, 409, { error: '这条命令的解释正在生成中,稍等一下再试' });
3990
+ return;
3991
+ }
3992
+ assistInFlight.add(sid);
3993
+ const last = session.shellState.lastCommand;
3994
+ const prompt = buildFailurePrompt({
3995
+ shell: session.kind === 'ssh' ? 'ssh ' + session.target : live.shell,
3996
+ cwd: session.cwd,
3997
+ exitCode: last === null ? null : last.exitCode,
3998
+ output: last === null ? '' : last.output,
3999
+ screen: session.screen === null ? '' : screenTextOf(session.screen),
4000
+ lang: body.lang === 'en' ? 'en' : 'zh',
4001
+ });
4002
+ const started = Date.now();
4003
+ try {
4004
+ const raw = await askModelOnce(llm, resolved.route, prompt);
4005
+ writeJson(res, 200, {
4006
+ ok: true,
4007
+ /*
4008
+ * 界面正文用**去标记后的纯文本**(围栏与 ** 都去掉):命令另有 command 字段
4009
+ * 专门展示,正文里再来一遍是重复;而把 Markdown 原样铺在界面上就是满屏星号。
4010
+ * 标记的解析在这里一次做完,客户端只负责原样显示。
4011
+ */
4012
+ answer: plainAnswerText(raw),
4013
+ // 「填入」按钮用:只在模型给了围栏代码块时非空(见 extractCommandFromAnswer)
4014
+ command: extractCommandFromAnswer(raw),
4015
+ route: resolved.route.provider + '/' + resolved.route.model,
4016
+ chars: prompt.user.length,
4017
+ ms: Date.now() - started,
4018
+ });
4019
+ }
4020
+ catch (error) {
4021
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
4022
+ }
4023
+ finally {
4024
+ // 无论成功、报错还是超时都要放行,否则这个会话从此再也问不动
4025
+ assistInFlight.delete(sid);
4026
+ }
4027
+ },
4028
+ }));
4029
+ /*
4030
+ * 模型候选(0.24.0):设置卡片里 provider / model 两栏的候选列表。
4031
+ *
4032
+ * 与 `/shells` 同档:loopback 围栏、纯只读、不改宿主状态,也不进 MUTATION_SUBROUTES。
4033
+ * **不看 assistEnabled**:卡片要在功能关着时也能把路由配好(同 /config 的理由)。
4034
+ * 也不带会话概念——它回答的是「宿主这台机器上有什么」,与终端会话无关。
4035
+ */
4036
+ registerGated({
4037
+ kind: 'exact',
4038
+ path: '/api/dsh-tty/model-catalog',
4039
+ handler: async (req, res) => {
4040
+ if (!(await gateRoute(req, res))) {
4041
+ return;
4042
+ }
4043
+ if (req.method !== 'GET') {
4044
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
4045
+ return;
4046
+ }
4047
+ const provider = (new URL(req.url ?? '/', 'http://loopback').searchParams.get('provider') ?? '').trim();
4048
+ const llm = getService(ctx, 'llm');
4049
+ const available = llm !== undefined && llm !== null && typeof llm.listModels === 'function';
4050
+ const providers = listProvidersOf(llm);
4051
+ writeJson(res, 200, {
4052
+ ok: true,
4053
+ // available=false 只用来把提示说准(「宿主没这个目录」vs「目录是空的」),不是错误
4054
+ available,
4055
+ providers,
4056
+ /*
4057
+ * 不带 provider:一次往返就把**每个** provider 的模型都取回来。控件的候选表是
4058
+ * 「渠道 + 模型」一张表,逐个 provider 再发一轮请求会把它变成 N 次往返。
4059
+ */
4060
+ groups: provider === '' ? await listGroupsOf(llm, providers) : [],
4061
+ // 带 provider:只问这一个(候选表要重取单个渠道时用)
4062
+ models: provider === '' ? [] : await listModelsOf(llm, provider),
4063
+ provider,
4064
+ });
4065
+ },
4066
+ });
3553
4067
  /*
3554
4068
  * 就地提权(`/elevate`、`/elevate/status`、`/elevate/revoke`)。
3555
4069
  *
@@ -4170,7 +4684,7 @@ const plugin = definePlugin({
4170
4684
  name: 'tty_list',
4171
4685
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4172
4686
  isConcurrencySafe: () => true,
4173
- description: '列出当前终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间),**含进程已退出但仍只读保留着的会话**(带 exited:true + 退出码/信号;按时间释放时另带 retainMs):用户开了终端面板后,用 tty_capture 读取某个 sid 的输出、用 tty_send 向该会话发送按键。已退出的会话只能读(写会报错),要接着操作请 tty_open 新开一条。',
4687
+ description: '列出当前终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间),**含进程已退出但仍只读保留着的会话**(带 exited:true + 退出码/信号;按时间释放时另带 retainMs)。**running** 说明这个会话有没有命令在跑(true = 在跑,现在别往里发命令,那些字节会被正在跑的程序当输入吃掉;false = 命令已结束;**省略 = 无法判断**——该会话没有 shell 集成标记,例如非持久 SSH / fish·csh / Windows 本地 / 集成被关,**不是**「没在跑」);同源的 lastExitCode / lastExitAt 是「上一条已完成命令」的退出码与结束时刻。用户开了终端面板后,用 tty_capture 读取某个 sid 的输出、用 tty_send 向该会话发送按键。已退出的会话只能读(写会报错),要接着操作请 tty_open 新开一条。',
4174
4688
  parameters: {},
4175
4689
  output: {
4176
4690
  schema: {
@@ -4197,6 +4711,9 @@ const plugin = definePlugin({
4197
4711
  exitCode: { type: 'number' },
4198
4712
  signal: { type: 'string' },
4199
4713
  retainMs: { type: 'number', description: '只读保留的剩余毫秒;省略 = 不按时间释放' },
4714
+ running: { type: 'boolean', description: '有命令正在执行;**省略 = 无法判断**(该会话没有 shell 集成标记),不等于「没在跑」' },
4715
+ lastExitCode: { type: 'number', description: '上一条已完成命令的退出码' },
4716
+ lastExitAt: { type: 'number', description: '上一条已完成命令的结束时刻(epoch ms)' },
4200
4717
  },
4201
4718
  },
4202
4719
  },
@@ -4210,11 +4727,24 @@ const plugin = definePlugin({
4210
4727
  const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
4211
4728
  const persist = s.persist === true ? ' [tmux 持久]' : '';
4212
4729
  const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
4730
+ // D88:三态必须在**渲染文本**里就分得开——agent 只看得到这段文本,
4731
+ // 而「无法判断」与「空闲」在旧写法里都渲染成「没有标记」,正好把新加的
4732
+ // 第三态糊掉了(用户验收现场:自己那条会话与既有的两条长得一模一样)。
4733
+ // 正在跑的会话尤其要显眼:往里发命令会被那个程序当输入吃掉。
4734
+ const runMark = s.exited === true
4735
+ ? ''
4736
+ : s.running === true
4737
+ ? ' [运行中——现在别往里发命令]'
4738
+ : s.running === false
4739
+ ? ' [空闲]'
4740
+ : ' [命令状态未知——该会话没有 shell 集成标记,发命令前先自己确认]';
4741
+ // 已退出会话的退出码由 gone 那条统管,这里只标活会话的「上一条」
4742
+ const lastExit = s.exited === true || s.lastExitCode === undefined ? '' : ` [上一条命令 exitCode=${String(s.lastExitCode)}]`;
4213
4743
  // D77:只读保留态必须显眼——否则 AI 会对着一个已经死掉的会话发命令
4214
4744
  const detail = s.signal !== undefined && s.signal !== '' ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
4215
4745
  const left = s.retainMs === undefined ? '(显式关闭前一直都在)' : ` ${String(Math.ceil(s.retainMs / 60000))} 分钟`;
4216
4746
  const gone = s.exited === true ? ` [已退出 ${detail}·只读保留${left}——只能读,写会报错]` : '';
4217
- return `\n- sid=${s.sid} [${s.kind}]${owner}${persist}${gone} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
4747
+ return `\n- sid=${s.sid} [${s.kind}]${owner}${persist}${runMark}${lastExit}${gone} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
4218
4748
  }).join('');
4219
4749
  return [{ type: 'text', text }];
4220
4750
  },
@@ -4285,6 +4815,82 @@ const plugin = definePlugin({
4285
4815
  return await server.closeAgentSession(input.sid);
4286
4816
  },
4287
4817
  })));
4818
+ activeDisposers.push(tools.register(defineTool({
4819
+ name: 'tty_run',
4820
+ description: '**一次性命令**:开一个终端会话跑 `command`,等它结束,直接返回尾部输出 + 退出码(会话出现在用户面板里、可见可接管)——把「tty_open → 等 → tty_capture{last} → tty_close」四步压成一次调用。命令**跑完就结束**:默认把这个会话关掉(结果已在本调用返回),`keep:true` 则留在「只读保留」态供回看。到 `timeoutSec` 还没结束会返回 `running:true`(会话照旧在跑,**杀不杀由你决定**),接着用 tty_expect / tty_capture 看,或 tty_close 关掉。何时用它而不是 bash 工具:需要用户**看得见**这条命令、或要跑在终端会话里(同一套 PTY / 会话名额 / 后续可接管)时用它;纯非交互、不需要用户看见的命令用 bash 工具更直接。`command` 按宿主 shell 的语法整段执行(POSIX 上 `cd x && cmd`、多行脚本都可以;Windows 的 cmd / PowerShell 按它们自己的语法)。',
4821
+ parameters: {
4822
+ command: { type: 'string', required: true, description: '要执行的命令(整段 shell 代码,支持 `cd x && cmd`、管道、多行脚本)' },
4823
+ cwd: { type: 'string', description: '工作目录(必须是已存在的绝对路径);缺省用插件配置的 cwd' },
4824
+ timeoutSec: { type: 'number', description: '等待命令结束的上限秒数(1~600,默认 120);到点没结束返回 running:true,会话不会被杀' },
4825
+ keep: { type: 'boolean', description: 'true = 跑完留在只读保留态(可回看/接管);默认 false = 跑完即关(结果已在本调用返回)' },
4826
+ lines: { type: 'number', description: '返回的输出尾部行数(1~500,默认 60)' },
4827
+ cols: { type: 'number', description: '列数(2~500,默认 80)' },
4828
+ rows: { type: 'number', description: '行数(2~200,默认 24)' },
4829
+ },
4830
+ output: {
4831
+ schema: {
4832
+ type: 'object',
4833
+ additionalProperties: false,
4834
+ properties: {
4835
+ sid: { type: 'string', required: true },
4836
+ running: { type: 'boolean', required: true },
4837
+ tail: { type: 'string', required: true },
4838
+ closed: { type: 'boolean', required: true },
4839
+ exitCode: { type: 'number' },
4840
+ signal: { type: 'string' },
4841
+ },
4842
+ },
4843
+ render: (_args, value) => {
4844
+ const v = value;
4845
+ const sid = v.sid ?? '?';
4846
+ if (v.running === true) {
4847
+ return [{ type: 'text', text: `终端会话 ${sid} 里这条命令到点还没结束(它就是面板里的普通标签,用户看得见、可接管):用 tty_expect 等它的就绪标记、tty_capture 读尾部,或 tty_close 关掉。\n\n${v.tail ?? ''}` }];
4848
+ }
4849
+ const how = v.signal !== undefined && v.signal !== '' ? `signal=${v.signal}` : `exitCode=${String(v.exitCode ?? '?')}`;
4850
+ const note = v.closed === true
4851
+ ? '会话已关闭(结果都在下面这段里)。'
4852
+ : `会话留在只读保留态(sid=${sid}),要回看这段输出可以再 tty_capture(用 lines 读尾部——命令型会话没有「上一条命令」,last:true 会报错)。`;
4853
+ return [{ type: 'text', text: `终端会话 ${sid} 的命令已结束(${how})。${note}\n\n${v.tail ?? ''}` }];
4854
+ },
4855
+ },
4856
+ async execute(args) {
4857
+ const input = args;
4858
+ if (typeof input.command !== 'string' || input.command.trim() === '')
4859
+ throw new Error('command 必须是非空字符串');
4860
+ const timeoutSec = clampInt(input.timeoutSec, 120, 1, 600);
4861
+ const lines = clampInt(input.lines, 60, 1, 500);
4862
+ const opened = await server.openAgentSession({
4863
+ command: input.command,
4864
+ ...(typeof input.cwd === 'string' && input.cwd.trim() !== '' ? { cwd: input.cwd } : {}),
4865
+ cols: input.cols,
4866
+ rows: input.rows,
4867
+ });
4868
+ const session = sessions.get(opened.sid);
4869
+ if (session === undefined || session.closed)
4870
+ throw new Error(`会话创建后立刻退役了: ${opened.sid}`);
4871
+ // 命令型会话的进程**就是**那条命令(D78 的 exec 链):进程退出 = 命令结束,
4872
+ // 与 shell 集成无关(这类会话不注入钩子,等 133;D 是等不到的)
4873
+ const finished = await waitForSessionExit(session, timeoutSec * 1000);
4874
+ const tail = cleanAnsiTail(tailLines(session, lines));
4875
+ if (!finished)
4876
+ return { sid: opened.sid, running: true, closed: false, tail };
4877
+ const exited = session.exited;
4878
+ const result = {
4879
+ sid: opened.sid,
4880
+ running: false,
4881
+ tail,
4882
+ closed: false,
4883
+ ...(exited === null || exited.code === null ? {} : { exitCode: exited.code }),
4884
+ ...(exited === null || exited.signal === null || exited.signal === '' ? {} : { signal: exited.signal }),
4885
+ };
4886
+ // keep 默认 false:这条通道的语义就是「一条命令一次调用」,留着会占满
4887
+ // 只读保留的名额(16 条);要回看现场就显式 keep:true
4888
+ if (input.keep === true)
4889
+ return result;
4890
+ await server.closeAgentSession(opened.sid);
4891
+ return { ...result, closed: true };
4892
+ },
4893
+ })));
4288
4894
  activeDisposers.push(tools.register(defineTool({
4289
4895
  name: 'tty_stats',
4290
4896
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
@@ -4357,7 +4963,7 @@ const plugin = definePlugin({
4357
4963
  name: 'tty_capture',
4358
4964
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4359
4965
  isConcurrencySafe: () => true,
4360
- description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。**进程已退出的会话也能读**(结果带 exited:true + 退出码/信号):输出在只读保留期里一直都在(保留到显式关闭或宿主重启,`tty_open command=...` 跑完一条命令后就这么用);这类会话不能再写,要接着操作请 tty_open 新开一条。',
4966
+ description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。**进程已退出的会话也能读**(结果带 exited:true + 退出码/信号):输出在只读保留期里一直都在(保留到显式关闭或宿主重启,`tty_open command=...` 跑完一条命令后就这么用);这类会话不能再写,要接着操作请 tty_open 新开一条。**命令型会话(`tty_open command=` / `tty_run`)没有「上一条命令」**(它们不注入 shell 集成钩子),对它们用 last:true 会明确报错——改用默认的 lines 读尾部。',
4361
4967
  parameters: {
4362
4968
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
4363
4969
  lines: { type: 'number', description: '读取尾部行数(1~500,默认 60);last:true 时忽略' },
@@ -4420,6 +5026,13 @@ const plugin = definePlugin({
4420
5026
  if (state.inCommand || (last !== null && last.endedAt < session.lastInputAt)) {
4421
5027
  return { sid: input.sid, source: 'last', inProgress: true, tail: '' };
4422
5028
  }
5029
+ // D87:命令型会话(`tty_open command=` / `tty_run`)**不注入 shell 集成钩子**,
5030
+ // 它们根本没有「上一条命令」这个概念——旧文案把用户指向「shell 不受支持 /
5031
+ // 集成被配置关闭」,方向完全反了(用户会去翻设置卡片找一个不存在的开关)。
5032
+ // 这类会话的**整条输出就是那条命令的输出**,要的是尾部读取。
5033
+ if (session.commandSession) {
5034
+ throw new Error(`会话 ${input.sid} 是命令型会话(tty_open 的 command= / tty_run):**整条输出就是那条命令的输出**,没有「上一条命令」这个概念(这类会话不注入 shell 集成钩子)。用不带 last 的尾部读取(lines,默认 60 行)或 tty_screen 拿结果`);
5035
+ }
4423
5036
  if (last === null) {
4424
5037
  throw new Error('暂无「上一条命令」记录(shell 集成未生效——shell 不受支持或被配置关闭——或尚未执行过命令);可改用 lines 读尾部');
4425
5038
  }
@@ -4482,13 +5095,6 @@ const plugin = definePlugin({
4482
5095
  const why = session.screenDownReason === null ? '' : `(${session.screenDownReason})`;
4483
5096
  throw new Error(`虚拟屏不可用: ${input.sid}${why}`);
4484
5097
  }
4485
- const buffer = screen.buffer.active;
4486
- const lines = [];
4487
- for (let row = 0; row < screen.rows; row++) {
4488
- lines.push(buffer.getLine(row)?.translateToString(true) ?? '');
4489
- }
4490
- while (lines.length > 0 && lines[lines.length - 1].trim() === '')
4491
- lines.pop();
4492
5098
  // 保尾截断:屏幕末尾(提示符行)才是有效区,丢头部不丢尾部
4493
5099
  return {
4494
5100
  sid: input.sid,
@@ -4496,7 +5102,7 @@ const plugin = definePlugin({
4496
5102
  rows: screen.rows,
4497
5103
  // D77:只读保留态如实标注(屏不再更新,「当前屏幕」= 退出那一刻)
4498
5104
  ...(session.exited === null ? {} : { exited: true, ...(session.exited.signal === null || session.exited.signal === '' ? {} : { signal: session.exited.signal }) }),
4499
- text: lines.join('\n').slice(-32 * 1024),
5105
+ text: screenTextOf(screen).slice(-32 * 1024),
4500
5106
  };
4501
5107
  },
4502
5108
  })));
@@ -4661,10 +5267,11 @@ const plugin = definePlugin({
4661
5267
  })));
4662
5268
  activeDisposers.push(tools.register(defineTool({
4663
5269
  name: 'tty_send',
4664
- description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送按键/文本(命令以 \\n 结尾;Windows 本地会话上插件会把 `\\n` 归一成 CRLF,照常写 `\\n` 即可)。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、回答提示)。操作会实时显示在用户的终端面板里。**对已退出(只读保留)的会话会报错**——那种会话只能读,要接着操作请 tty_open 新开一条。',
5270
+ description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送文本与/或按键(data 含换行则以回车提交;Windows 本地会话上插件会把 `\\n` 归一成 CRLF,照常写 `\\n` 即可)。**控制键与方向键用具名 `keys`,不要自己在 data 里拼转义序列**:`"\\x1b[B"` / `"^[[B"` 这类写法会被当成普通字符打印进终端(而 sent 计数一样,看不出错)。data 与 keys 至少给一个;两个都给时先发 data、再按序发 keys(如 data=":wq" + keys=["Enter"])。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、vim/less 的翻页、回答提示)。操作会实时显示在用户的终端面板里。**对已退出(只读保留)的会话会报错**——那种会话只能读,要接着操作请 tty_open 新开一条。',
4665
5271
  parameters: {
4666
5272
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
4667
- data: { type: 'string', required: true, description: '要发送的文本(含换行则直接发送命令)' },
5273
+ data: { type: 'string', description: '要发送的文本(含换行则直接发送命令);与 keys 至少给一个' },
5274
+ keys: { type: 'array', items: { type: 'string' }, description: `具名按键数组,按序发送:${KEY_VOCABULARY}。例:["C-c"]、["Down","Down","Enter"]、["Esc", ":", "w", "q", "Enter"]` },
4668
5275
  },
4669
5276
  output: {
4670
5277
  schema: {
@@ -4675,17 +5282,31 @@ const plugin = definePlugin({
4675
5282
  sent: { type: 'number', required: true },
4676
5283
  },
4677
5284
  },
4678
- render: (_args, value) => {
5285
+ render: (renderArgs, value) => {
4679
5286
  const v = value;
4680
- return [{ type: 'text', text: `已向终端会话发送 ${v.sent ?? 0} 个字符` }];
5287
+ const a = renderArgs;
5288
+ const shown = Array.isArray(a.keys) ? a.keys.filter((key) => typeof key === 'string' && key !== '') : [];
5289
+ const note = shown.length === 0 ? '' : `(含按键 ${shown.join(' ')})`;
5290
+ return [{ type: 'text', text: `已向终端会话发送 ${v.sent ?? 0} 个字符${note}` }];
4681
5291
  },
4682
5292
  },
4683
5293
  async execute(args) {
4684
5294
  const input = args;
4685
5295
  if (typeof input.sid !== 'string' || input.sid === '')
4686
5296
  throw new Error('sid 必须是非空字符串');
4687
- if (typeof input.data !== 'string' || input.data === '')
4688
- throw new Error('data 必须是非空字符串');
5297
+ if (input.keys !== undefined && !Array.isArray(input.keys))
5298
+ throw new Error('keys 必须是字符串数组');
5299
+ const keys = Array.isArray(input.keys) ? input.keys.map((key) => {
5300
+ if (typeof key !== 'string')
5301
+ throw new Error('keys 里只能放字符串(按键名或单个字符)');
5302
+ return key;
5303
+ }) : [];
5304
+ const hasData = typeof input.data === 'string' && input.data !== '';
5305
+ if (!hasData && keys.length === 0) {
5306
+ throw new Error(`data 与 keys 至少要有一个:data 是要发送的文本,keys 是具名按键数组(${KEY_VOCABULARY})`);
5307
+ }
5308
+ // 按键名先解析(未知名字在这里就报错,别等写进 PTY 才发现)
5309
+ const keyBytes = resolveKeys(keys);
4689
5310
  const session = sessions.get(input.sid);
4690
5311
  if (session === undefined || session.closed)
4691
5312
  throw new Error(`会话不存在或已退出: ${input.sid}`);
@@ -4693,9 +5314,12 @@ const plugin = definePlugin({
4693
5314
  // D77:只读保留态明确拒写——进程已经没了,写进去只会在死 PTY 上静默消失
4694
5315
  throw new Error(`会话 ${input.sid} 的进程已退出(${describeExit(session.exited)}),只读保留中,写不进去:要接着操作请 tty_open 新开一条会话(它最后的输出仍可用 tty_capture / tty_screen 读)`);
4695
5316
  }
5317
+ // 0.23.0:data 在前、keys 在后按序拼(两个都给时语义固定,见工具描述)。
5318
+ // 具名按键走同一份归一化:Windows 本地会话的 Enter 同样要 CRLF(D74)。
5319
+ const raw = (hasData ? input.data : '') + keyBytes;
4696
5320
  // D74:Windows 本地 PTY 的 Enter 是 CR,裸 LF 不提交命令行——按平台归一化。
4697
5321
  // SSH 会话不动:远端是什么系统插件不知道。
4698
- const data = session.kind === 'local' ? normalizePtyInput(input.data) : input.data;
5322
+ const data = session.kind === 'local' ? normalizePtyInput(raw) : raw;
4699
5323
  session.lastInputAt = Date.now();
4700
5324
  // D72:只初始化水位线,**不推进**——刚发出去的这条命令的输出 AI 还没看见;
4701
5325
  // 但这一刻之前的积压不该被第一次 expect 当成「未读」回扫。
@@ -5123,7 +5747,7 @@ const plugin = definePlugin({
5123
5747
  },
5124
5748
  })));
5125
5749
  stateRef.toolsRegistered = true;
5126
- console.log('[dsh-tty] agent tools registered (tty_list, tty_open, tty_close, tty_stats, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
5750
+ console.log('[dsh-tty] agent tools registered (tty_list, tty_open, tty_close, tty_run, tty_stats, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
5127
5751
  };
5128
5752
  refreshToolsHook = registerAll;
5129
5753
  registerAll();
@@ -5192,7 +5816,17 @@ const plugin = definePlugin({
5192
5816
  : '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + alive.map((s) => {
5193
5817
  const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
5194
5818
  const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
5195
- return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
5819
+ // D88:这里只给**非空闲**的两种态打标(「没有标记」= 空闲且可信)——
5820
+ // 每轮的增量只在异常态出现,三态又不会糊成两种。省略态必须看得见:
5821
+ // 一个「不知道在不在跑」的会话最不该被当成空闲。
5822
+ // 措辞与 tty_list 的渲染**逐字一致**(两处都给「现在」:它才是那句里
5823
+ // 真正要 agent 照做的部分;每轮多 2 个字符换掉一处措辞漂移,值)
5824
+ const busy = s.running === true
5825
+ ? ' [运行中——现在别往里发命令]'
5826
+ : s.running === false
5827
+ ? ''
5828
+ : ' [命令状态未知]';
5829
+ return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''}${busy} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
5196
5830
  }).join('\n');
5197
5831
  const tail = gone.length === 0
5198
5832
  ? ''