新 scope 设计:ports / services / workspace / printers
August 12, 2026 · View on GitHub
状态:已按本文实现(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 全部遵守)
- 只读。任何数据源都是读操作;模型输入永远不进 shell 参数。
- 无 secrets。不读 token、私钥、
.env内容、云凭据。 - 失败也是事实。平台不支持、后端缺失、解析失败 →
available: false (reason), 绝不抛工具错误(基础设施失败除外)。 - 工作量可控且结果不误导。默认输出行数由
maxPorts/maxServices封顶,返回total/truncated;显式设为0可关闭条目数截断。内容长度和子进程时间仍有边界。 - 可测。解析器是纯函数(fixture 驱动单测),平台后端经
NativeRunner/AppDirectoryReader注入。 - 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 <platform>) |
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
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 (<reason>)。进程/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 (<error>) |
| 解析出 0 行但命令成功 | available: true + listening: [](真没有监听,是事实) |
| 其他平台 | `available: false (port probing is unsupported on |
\text{Token} 成本
上限 ≈ 128 行 \times ~45 字符 ≈ 6 \text{KB};典型开发机 < 50 个监听 ≈ 2 \text{KB}。 \text{lsof}/\text{ss}/\text{netstat} 都快(<300\text{ms}),不加 \text{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
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 (<reason>)。
配置
| 字段 | 默认 | 说明 |
|---|---|---|
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 (<error>) |
| 解析 0 行但命令成功 | available: true + 空列表 |
| 其他平台 | `available: false (service probing is unsupported on |
\text{Token} 成本
上限 ≈ 200 行 \times ~60 字符 ≈ 12 \text{KB}(有状态描述时);无描述典型 ≈ 4–6 \text{KB}。 后端都快(<500\text{ms}),不加缓存。
测试策略
- $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 内的白名单路径)
- VCS:
.git/.hg/.svn目录存在性 →vcs: 'git' | 'hg' | 'svn'。 - 包管理器:
- 通过限长文件句柄读
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 但有锁文件 → 名字来自锁文件,无版本。
- 通过限长文件句柄读
- Node 版本钉:
.nvmrc、.node-version内容(≤4 KB 读、≤80 字符保留);package.json的engines.node。 - 语言/工具版本钉:
.tool-versions、.python-version、.ruby-version、.go-version、.terraform-version内容(同上限)。 - 构建/环境标记文件(存在性,目录条目名匹配,绝不读内容):
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。 - env 文件:
.env、.env.example存在性(文件名入列,内容永不读)。
Canonical schema
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 <name> 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
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 (<reason>)。行序按名字升序。
配置
无新配置项。names 参数支持:按打印机名大小写不敏感精确匹配过滤
(与 apps 的 names 语义一致,走自由文本匹配而非严格探测名校验)。
安全边界
lpstat固定参数(-p/-d),只读;命令名固定。- Windows 脚本只 SELECT 白名单字段。
- 输出行数天然有界(打印机数量),无需 cap。
失败模式
| 情形 | 结果 |
|---|---|
| lpstat 缺失(非 CUPS 环境) | available: false (lpstat not found) |
| 命令超时 | available: false (<error>) |
| 命令成功但 0 台 | available: true + (none found)(含中文「未添加目的位置」) |
| 其他平台 | available: false (printer probing is unsupported on <platform>) |
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. 待确认问题(实现时已决议)
- 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/<uid>作为 v1 未采用的备选。
- 解析器区分「命令成功但输出空」(= 用户域无作业,
- netstat 回退(已实现):
ports在 macOS(lsof 缺失)与 Linux(ss 缺失)都实现了 netstat 回退,Linux 另有/proc/net/tcp纯 Node 兜底(无 pid,地址/端口内核级可见)。 - 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还原为空格。