# 新 scope 设计:ports / services / workspace / printers > 状态:**已按本文实现**(2025-08)。本文保留为设计基线;实现与测试见 > `src/probe.ts`、`src/render.ts`、`src/index.ts` 与 `tests/{ports,services,workspace,printers}.spec.ts`。 > 实现遵循与既有 scope(environment/commands/software/resources/apps/serial/usb/network/gpu) > 完全一致的模式:只读、无 shell、无 secrets、canonical JSON + 人类可读渲染、 > 纯函数解析器 + 可注入 runner、失败也是事实。 --- ## 0. 共享设计原则(新 scope 全部遵守) 1. **只读**。任何数据源都是读操作;模型输入永远不进 shell 参数。 2. **无 secrets**。不读 token、私钥、`.env` 内容、云凭据。 3. **失败也是事实**。平台不支持、后端缺失、解析失败 → `available: false (reason)`, 绝不抛工具错误(基础设施失败除外)。 4. **工作量可控且结果不误导**。默认输出行数由 `maxPorts`/`maxServices` 封顶,返回 `total`/`truncated`;显式设为 `0` 可关闭条目数截断。内容长度和子进程时间仍有边界。 5. **可测**。解析器是纯函数(fixture 驱动单测),平台后端经 `NativeRunner` / `AppDirectoryReader` 注入。 6. **canonical 结果**:原始数字/枚举,不是格式化字符串;Code Mode 消费者直接用字段。 平台后端约定(与现有 battery/device/usb/gpu 一致): | 平台 | 方式 | |---|---| | macOS | 固定参数的 `execFile`(lsof / launchctl / lpstat) | | Linux | 优先固定参数命令(ss / systemctl / lpstat),纯 Node 读取作为最终回退 | | Windows | PowerShell CIM/内置 cmdlet 单脚本 → stdout JSON | | 其他 | `available: false (… unsupported on )` | --- ## 1. `ports` — 监听端口 ### 目标 运维排障第一问:「这台机器上哪些端口在监听、谁在监听」。agent 据此判断 「dev server 是否在跑」「端口是否被占」「该连哪个端口」,不必 `lsof`/`netstat` 逐个试错。 ### 数据源与平台策略 只报 **TCP LISTEN** 套接字(UDP 噪声大,v1 不做;见决策记录 D1)。 | 平台 | 主后端 | 回退 | 说明 | |---|---|---|---| | macOS | `lsof -nP -iTCP -sTCP:LISTEN` | `netstat -an -p tcp`(无 pid/进程名) | 本机已验证:输出含 `COMMAND PID USER … NAME`,NAME 形如 `*:57329 (LISTEN)` | | Linux | `ss -lntpH` | `netstat -lntp` → 纯 Node 读 `/proc/net/tcp` + `/proc/net/tcp6`(无 pid) | `ss` 对当前用户可见进程报 pid/进程名,他人进程不可见(权限事实) | | Windows | PowerShell:`Get-NetTCPConnection -State Listen` → JSON | — | 无需管理员即可列监听端口与 OwningProcess | 进程可见性:**只报当前用户可见的进程**。无特权时其他用户的监听端口不出现—— 这是权限事实,不是 bug,文档写明。 ### Canonical schema ```ts export interface PortFacts { scope: 'ports' available: boolean /** 为什么不可用(平台不支持/后端缺失/解析失败)。 */ error?: string /** TCP 监听套接字,按端口号升序,同 (地址,端口) 去重。 */ listening: PortEntry[] } export interface PortEntry { /** 本地地址原样(`*`、`127.0.0.1`、`[::1]`、`0.0.0.0` …)。 */ address: string port: number /** 进程名(lsof/ss/Windows 有;netstat 回退无)。 */ process?: string /** 拥有进程 pid(后端提供时)。 */ pid?: number } ``` IPv4/IPv6 不单独成字段:地址含 `:` 即为 IPv6,渲染器/消费者自判。 ### 渲染示例 ``` [ports] listening: 23 sockets *:5173 (node, pid 8123) 127.0.0.1:5432 (postgres, pid 501) [::1]:631 (cupsd, pid 318) *:57329 (rapportd, pid 553) … (23 listening TCP sockets) ``` 行序:端口号升序;无监听 → `listening: (none found)`; 不可用 → `available: false ()`。进程/pid 缺失时省略 `(process, pid N)`。 ### 配置 | 字段 | 默认 | 说明 | |---|---|---| | `maxPorts` | `128` | 单次返回的监听套接字上限;`0` 不按条目数截断。结果用 `total`/`truncated` 明示完整性 | `names` 参数对 `ports` 忽略。 ### 安全边界 - 命令与参数全部固定(`lsof -nP -iTCP -sTCP:LISTEN`),模型输入不进入命令行。 - `-nP` 不做 DNS 反查、不打印端口服务名(避免慢解析与歧义)。 - 默认输出行数由 `maxPorts` 封顶;`0` 可请求完整可见集合,canonical 结果始终用 `total`/`truncated` 说明是否截断。 ### 失败模式 | 情形 | 结果 | |---|---| | lsof/ss 缺失且回退也缺失 | `available: false (no port-listing backend found)` | | 命令超时/被杀(NativeRunner 5s 超时) | `available: false ()` | | 解析出 0 行但命令成功 | `available: true` + `listening: []`(真没有监听,是事实) | | 其他平台 | `available: false (port probing is unsupported on )` | ### Token 成本 上限 ≈ 128 行 × ~45 字符 ≈ 6 KB;典型开发机 < 50 个监听 ≈ 2 KB。 lsof/ss/netstat 都快(<300ms),**不加 TTL 缓存**(`all` 里每调用一次即新鲜)。 ### 测试策略 - `parseLsofListening(output, cap)`:fixture(含 IPv4/IPv6/多进程同端口/无括号行/乱码行)。 - `parseSsListening(output, cap)`:fixture(含 `users:(("name",pid=1234,fd=18))` 与无 users 段)。 - `parseNetstatListening`、`parseWindowsPorts(json)` 同上。 - `collectPorts` 注入 runner:后端缺失回退链、超时、空输出。 - 平台 e2e:macOS 本机跑真 lsof(断言 `*:NNNN` 行存在)。 --- ## 2. `services` — 服务状态 ### 目标 「哪些系统服务没在跑/失败了」。agent 排障时先看失败服务,再决定下一步 (看日志、重启、查配置),而不是盲跑 `ps`。 ### 数据源与平台策略 只列**当前用户可见域**的服务(macOS 用户域、Linux systemd 全量列表是只读的、 Windows 服务列表无需管理员)。 | 平台 | 主后端 | 说明 | |---|---|---| | macOS | `launchctl list` | 用户域 launchd 作业;`PID Status Label` 三列,PID 为 `-` 表示未运行,Status 为上次退出码 | | Linux | `systemctl list-units --type=service --all --no-pager --no-legend` | 只读、无需 root;列 `UNIT LOAD ACTIVE SUB DESCRIPTION` | | Windows | PowerShell:`Get-Service | Select Status,Name,DisplayName` → JSON | 列出全部服务 | Linux 无 systemd(`systemctl` 缺失)→ `available: false (no systemd)`,**不做 进程列表回退**(进程列表不是服务状态,诚实优于猜测;D2)。 ### Canonical schema ```ts export interface ServiceFacts { scope: 'services' available: boolean error?: string /** 可见域:'launchd:user' | 'systemd:system' | 'windows'。 */ domain?: string services: ServiceFact[] } export interface ServiceFact { /** launchd label / systemd 单元名 / Windows 服务名。 */ name: string state: 'running' | 'stopped' | 'failed' | 'activating' | 'deactivating' | 'unknown' pid?: number /** 平台原始状态串(systemd SUB、Windows Status 原文),canonical 映射不丢信息。 */ detail?: string /** launchd 上次退出码(仅 macOS)。 */ lastExitCode?: number /** systemd DESCRIPTION / Windows DisplayName(后端提供时)。 */ description?: string } ``` 状态映射: | canonical | macOS launchctl | Linux systemd SUB | Windows Status | |---|---|---|---| | `running` | PID 非 `-` | running | Running | | `stopped` | PID 为 `-`(Status 0) | dead / exited | Stopped | | `failed` | PID 为 `-` 且 Status ≠ 0 | failed | — | | `activating` | — | activating / auto-restart | StartPending | | `deactivating` | — | deactivating | StopPending | | `unknown` | 其余 | 其余 | 其余(如 Paused) | ### 渲染示例 ``` [services] domain: launchd:user com.apple.AirPlayXPCHelper: running (pid 396) … org.postgresql.postgres: failed (last exit 1) ssh.service: running (pid 812) — OpenBSD Secure Shell server (43 services: 38 running, 4 stopped, 1 failed) ``` 行序:**先坏后好**(failed → activating/deactivating → running → stopped → unknown), 同等级按名字升序——排障时问题服务永远在最前面。汇总行带各状态计数。 无服务 → `services: (none found)`;不可用 → `available: false ()`。 ### 配置 | 字段 | 默认 | 说明 | |---|---|---| | `maxServices` | `200` | 单次返回的服务上限;`0` 不按条目数截断,`total`/`truncated` 明示完整性 | `names` 参数对 `services` 忽略。 ### 安全边界 - 全部固定参数、只读命令;`launchctl list` 无参数;`systemctl list-units` 为只读子命令。 - 进程可见性同 `ports`:无特权只看到权限内的服务/单元。 ### 失败模式 | 情形 | 结果 | |---|---| | systemctl 缺失 / 非 systemd 发行版 | `available: false (no systemd)` | | launchctl/systemctl/Get-Service 超时 | `available: false ()` | | 解析 0 行但命令成功 | `available: true` + 空列表 | | 其他平台 | `available: false (service probing is unsupported on )` | ### Token 成本 上限 ≈ 200 行 × ~60 字符 ≈ 12 KB(有状态描述时);无描述典型 ≈ 4–6 KB。 后端都快(<500ms),不加缓存。 ### 测试策略 - `parseLaunchctlList`:fixture(含 PID `-`、退出码 0/非 0、空输出、多余空白列)。 - `parseSystemctlUnits`:fixture(running/failed/dead/exited/activating、含 description 列)。 - `parseWindowsServices(json)`:fixture(Running/Stopped/StartPending/Paused)。 - `collectServices` 注入 runner:回退链、超时、空输出。 - macOS e2e:真 `launchctl list`,断言 label 行存在。 --- ## 3. `workspace` — 项目工具链信号 ### 目标 开发者场景:agent 进入一个仓库后,**不猜**该用 pnpm 还是 npm、Node 版本钉在多少、 构建系统是什么。纯读当前工作目录的**文件存在性**与少数白名单小文件内容。 ### 关键安全属性:零子进程 **本 scope 永不执行任何东西**——不跑 `git status`、不跑 `ls`、不跑包管理器。 项目目录可能是敌意的(恶意 `package.json` 的 `postinstall`、`ls` 别名陷阱), 纯 `fs` 读取是唯一安全边界。这是与其余 scope 最大的不同,写死在设计里。 ### 数据源(全部为 cwd 内的白名单路径) 1. **VCS**:`.git` / `.hg` / `.svn` 目录存在性 → `vcs: 'git' | 'hg' | 'svn'`。 2. **包管理器**: - 通过限长文件句柄读 `package.json`(≤512 KB;最终符号链接拒绝),再 `JSON.parse`,取 `packageManager` 字段(如 `pnpm@9.1.0`)。 - 锁文件按优先级探测:`pnpm-lock.yaml`/`pnpm-workspace.yaml`(pnpm) → `yarn.lock`(yarn) → `package-lock.json`/`npm-shrinkwrap.json`(npm) → `bun.lockb`/`bun.lock`(bun)。`packageManager` 字段与锁文件冲突时两者都如实上报。 - 无 package.json 但有锁文件 → 名字来自锁文件,无版本。 3. **Node 版本钉**:`.nvmrc`、`.node-version` 内容(≤4 KB 读、≤80 字符保留); `package.json` 的 `engines.node`。 4. **语言/工具版本钉**:`.tool-versions`、`.python-version`、`.ruby-version`、 `.go-version`、`.terraform-version` 内容(同上限)。 5. **构建/环境标记文件**(存在性,目录条目名匹配,绝不读内容): `Makefile`、`CMakeLists.txt`、`meson.build`、`build.gradle`、`settings.gradle`、 `pom.xml`、`Cargo.toml`、`go.mod`、`pyproject.toml`、`requirements.txt`、 `setup.py`、`Gemfile`、`composer.json`、`mix.exs`、`stack.yaml`、`flake.nix`、 `shell.nix`、`default.nix`、`Dockerfile`、`docker-compose.yml`、`compose.yaml`、 `.github/workflows`(目录)、`Jenkinsfile`、`.gitlab-ci.yml`、`justfile`、 `Taskfile.yml`。 6. **env 文件**:`.env`、`.env.example` **存在性**(文件名入列,**内容永不读**)。 ### Canonical schema ```ts export interface WorkspaceFacts { scope: 'workspace' /** 探测的工作目录(会话 cwd)。 */ cwd: string /** cwd 可读(readdir 成功)。 */ readable: boolean /** readdir 失败原因(cwd 已删/无权限);`readable: false` 时其余字段为空。 */ error?: string vcs?: 'git' | 'hg' | 'svn' packageManager?: { name: string; version?: string; lockfile?: string } /** 版本钉:{ file, value },value ≤80 字符。 */ nodePins: { file: string; value: string }[] versionPins: { file: string; value: string }[] /** 匹配到的标记文件名,升序。 */ markers: string[] /** 发现的 env 文件名(存在性,绝不读内容)。 */ envFiles: string[] } ``` ### 渲染示例 ``` [workspace] cwd: /Users/developer/Projects/dsh-scout vcs: git package manager: pnpm (9.1.0, pnpm-lock.yaml) node pins: .nvmrc = 20.11.0 version pins: .tool-versions = nodejs 20.11.0 markers: Dockerfile, Makefile, pyproject.toml env files: .env (presence only, contents not read) ``` 无 VCS → 省略 `vcs:` 行;无包管理器 → `package manager: (none detected)`; 无标记 → `markers: (none)`;无 env 文件 → 省略。不可读 → `available: false` 风格: `readable: false (ENOENT: …)`。 ### 配置 | 字段 | 默认 | 说明 | |---|---|---| | `workspaceMarkers` | 上文第 5 条目录 | 标记文件名清单(可增删) | | `workspaceVersionFiles` | 上文第 3、4 条文件 | 版本钉文件清单 | `names` 参数对 `workspace` 忽略。 ### 安全边界 - **零子进程**(唯一不变式)。 - 只探测白名单文件名;`readdir` 一次,逐名匹配,不递归。 - 只读三类小文件的内容(版本钉 ≤4 KB、`package.json` ≤512 KB),读取前验证 basename,生产 reader 拒绝最终符号链接,保留值 ≤80 字符。 - `.env` 只报存在性——内容永不读取,注释写死在解析器上。 ### 失败模式 | 情形 | 结果 | |---|---| | cwd 不可读(readdir 失败) | `readable: false` + error,其余字段空 | | 某版本钉文件不可读 | 该文件跳过(缺失即无钉,是事实) | | `package.json` 超限/JSON 非法 | `packageManager` 缺席;锁文件推断照常 | ### Token 成本 固定小:~15–25 行 ≈ 1–1.5 KB(标记目录是封闭清单,不可能膨胀)。纯 fs,无超时问题。 ### 测试策略 - `collectWorkspace(cwd, reader)` 注入 `AppDirectoryReader`:fixture 目录(临时目录内 构造各标记/锁文件/版本钉),断言:包管理器推断优先级、`packageManager` 字段解析 (含 `pnpm@9.1.0` 与无版本两种)、引擎钉、`.env` 只在 envFiles、超限内容截断、 cwd 不可读。 - 回归:在 dsh-scout 自身目录上跑真 `collectWorkspace`,断言 `vcs: git`、 `packageManager: pnpm`(本仓库 pnpm-workspace.yaml 存在)。 --- ## 4. `printers` — 打印机 ### 目标 文员场景:机器上有哪些打印机、默认打印机是谁、状态如何。agent 据此判断 「打印任务该投给谁」「打印机是否禁用」。 ### 数据源与平台策略 | 平台 | 主后端 | 说明 | |---|---|---| | macOS | `lpstat -p` + `lpstat -d` | 本机已验证存在;`lpstat -p` 输出 `printer is idle/disabled…`,`lpstat -d` 报默认 | | Linux | `lpstat -p` + `lpstat -d` | CUPS 同样适用;lpstat 缺失 → `available: false (lpstat not found)` | | Windows | PowerShell:`Get-CimInstance Win32_Printer` → JSON | Name/DriverName/PortName/PrinterStatus/Default | `lpstat` 中文 locale 输出(如本机 `lpstat: 未添加目的位置。`)视为 **0 台打印机**—— 命令成功 + 空输出是事实,不是错误。 ### Canonical schema ```ts export interface PrinterFacts { scope: 'printers' available: boolean error?: string /** 默认打印机名(lpstat -d / Windows Default)。 */ defaultName?: string printers: PrinterFact[] } export interface PrinterFact { name: string status: 'idle' | 'busy' | 'disabled' | 'error' | 'offline' | 'unknown' /** Windows DriverName。 */ driver?: string /** Windows PortName。 */ port?: string } ``` 状态映射:lpstat 文案 `is idle` → idle、`is busy` → busy、`is disabled` → disabled、 其余 → unknown;Windows PrinterStatus:3→idle、4→busy、6→error、7→offline、 8→error(卡纸)、其余→unknown。 ### 渲染示例 ``` [printers] default: HP_LaserJet HP_LaserJet: idle Brother_QL: disabled (driver Brother QL-800, port USB001) (2 printers found) ``` 无打印机 → `printers: (none found)`;无默认 → 省略 `default:` 行; 不可用 → `available: false ()`。行序按名字升序。 ### 配置 无新配置项。`names` 参数**支持**:按打印机名大小写不敏感精确匹配过滤 (与 `apps` 的 `names` 语义一致,走自由文本匹配而非严格探测名校验)。 ### 安全边界 - `lpstat` 固定参数(`-p` / `-d`),只读;命令名固定。 - Windows 脚本只 SELECT 白名单字段。 - 输出行数天然有界(打印机数量),无需 cap。 ### 失败模式 | 情形 | 结果 | |---|---| | lpstat 缺失(非 CUPS 环境) | `available: false (lpstat not found)` | | 命令超时 | `available: false ()` | | 命令成功但 0 台 | `available: true` + `(none found)`(含中文「未添加目的位置」) | | 其他平台 | `available: false (printer probing is unsupported on )` | ### Token 成本 极小:每台一行 ≈ 60 字符;典型 <10 台,<1 KB。lpstat 快,不加缓存。 ### 测试策略 - `parseLpstatPrinters(output)`:fixture(idle/disabled/busy、英文与中文空输出、 多行续行、`lpstat -d` 无默认)。 - `parseWindowsPrinters(json)`:fixture(各 PrinterStatus 码、无默认)。 - `collectPrinters` 注入 runner:缺失回退、超时、空输出。 - macOS e2e:真 `lpstat -p`,断言 `available: true`(本机 0 台 → 空列表)。 --- ## 5. 集成点(实现清单) | 文件 | 改动 | |---|---| | `src/probe.ts` | 新增 4 组接口与收集函数:`PortFacts/collectPorts`、`ServiceFacts/collectServices`、`WorkspaceFacts/collectWorkspace`、`PrinterFacts/collectPrinters`;全部解析器为纯函数导出 | | `src/render.ts` | `renderPorts`/`renderServices`/`renderWorkspace`/`renderPrinters` + `renderResult`/`SingleScoutResult`/`AllScoutResult` 扩展 | | `src/index.ts` | `SCOPES`/`SINGLE_SCOPES` 加入 4 项;新增 4 个 canonical schema 并纳入 `SINGLE_SCOPE_SCHEMAS`/`ALL_SCHEMA`;`collectSingle`/`collectAll` 分支;`TOOL_DESCRIPTION` 补一句;Config 加 `maxPorts`/`maxServices`/`workspaceMarkers`/`workspaceVersionFiles`;`names` 描述更新(printers 支持) | | `src/probe.ts`(提示词) | `index.ts` 内 `tool:scout` 段落补充:`…before assuming listening ports, service state, project toolchain, or printers…` | | `tests/` | `ports.spec.ts`、`services.spec.ts`、`workspace.spec.ts`、`printers.spec.ts`(fixture 驱动);`loader.spec.ts` 注册表管线补 4 个 scope 的冒烟 | | `README.zh.md` / `README.md` | 工具表补 4 行;「已知限制与待办」更新(移走已完成项) | `all` 语义:**包含全部新 scope**(它是显式 opt-in,代价见下)。 ### `all` 的 Token 影响 `all` 将新增 ~8–20 KB 输出(ports 2–6 KB + services 4–12 KB + workspace ~1.5 KB + printers <1 KB)。设计上接受:`all` 是穷举请求,agent 应该按需取 scope; 文档在 `TOOL_DESCRIPTION` 里已强调「request only the scope you need」。 --- ## 6. 决策记录(ADR) | # | 决策 | 理由 | |---|---|---| | D1 | `ports` 只做 TCP LISTEN,不做 UDP | UDP 行数是 TCP 的数倍且多为噪音;v1 聚焦「能否连上」 | | D2 | `services` 在无 systemd 时不回退进程列表 | 进程 ≠ 服务状态;诚实报告 `available: false` 优于猜测 | | D3 | `workspace` 零子进程 | 项目目录不可信;`git status`/`npm` 都可能触发钩子或慢执行 | | D4 | 空输出语义:ports/services/printers 空 = 真事实(`available: true` + 空列表);usb/gpu 沿用旧语义(空 = `available: false`) | lpstat/ss 空输出是「确实没有」,system_profiler 空树常常是后端坏了;按后端可靠性区分,不统一 | | D5 | `services` 渲染先坏后好(failed 在最前) | 排障场景问题服务必须一眼可见 | | D6 | `ports` 不加 TTL 缓存 | lsof/ss <300ms;缓存反而给 agent 过期事实。慢后端(system_profiler 等)才缓存 | | D7 | `printers` 支持 `names` 过滤,ports/services/workspace 忽略 `names` | printers 数量可多可少需要过滤;其余有天然 cap 且无名字概念 | | D8 | `all` 包含新 scope | 保持「all = 全部」的直观语义;代价在文档中明示 | ## 7. 待确认问题(实现时已决议) 1. **launchctl 可达性(已实测定案)**:在 dsh agent 的 shell 上下文里 `launchctl list` 退出码 1、零输出、空 stderr(进程不在用户 GUI bootstrap 域,连不上 launchd)。实现: - 解析器区分「命令成功但输出空」(= 用户域无作业,`available: true` + 空列表)与「命令非零退出」(= `available: false (launchctl list failed with exit N)`); - macOS e2e 测试把两种结果都当作合法事实断言; - `launchctl print gui/` 作为 v1 未采用的备选。 2. **netstat 回退(已实现)**:`ports` 在 macOS(lsof 缺失)与 Linux(ss 缺失)都实现了 netstat 回退,Linux 另有 `/proc/net/tcp` 纯 Node 兜底(无 pid,地址/端口内核级可见)。 3. **CI 标记目录(保持精简)**:`workspaceMarkers` 只含 `.github/workflows`,未加入 `.circleci/`、`.travis.yml`——v1 保持精简单。 另外两处实现期发现(已落入测试与 README「已知限制」): - **`lpstat` 非零退出是常态**:本机 `lpstat -p` 无目的地时 exit 1 且输出在 stderr(中文「未添加目的位置」)。`collectPrinters` 默认使用宽容 runner(`defaultTolerantRunner`,进程跑过即保留输出),得到 `printers: (none found)` 而非错误。 - **lsof 进程名带 `\xNN` 转义**:如 `Code\x20Helper`;解析器经 `decodeLsofName` 还原为空格。