DuiqiDock 使用文档
五类图标 · 指示器脚本语法 · AI Agent 通道
图标是什么
DuiqiDock 上每个图标都是一个组件:由「类型 + 该类型的配置」定义, 自己回答三个问题 —— 要打开哪个地址、画什么图标、 现在是什么状态。类型决定了后面要填哪些字段。
| 类型 | 填什么 | 打开什么 | 依赖 |
|---|---|---|---|
| 网站 | 网址 | 该网址 | 无(图标用系统自带 curl.exe 抓) |
| 本地 | 启动命令、工作目录、端口 | 本机起进程 → 从它的输出里认出监听地址,认不出就试你填的端口 | 无 |
| 远程 | ssh 目标、启动命令、端口 | ssh 在那台机器上起进程 → 从它的日志里解析出地址 |
系统 ssh.exe(Win10+ 自带) |
| 扩展 | 入口命令、工作目录、git 仓库(可选) | (填了仓库就先 clone 到应用数据目录)→ 跑入口 → 它起的 web server | 系统 git.exe(只有填了仓库才用得上) |
| 指示器 | 一段 JS | 不打开任何东西:一个会闪的彩色圆点 | Node.js(五类里唯一需要另装的) |
怎么加一个图标
- 点 dock 下方的 +(图标多的时候它还是最后一行)。
- 在编辑器里选类型,填该类型的字段 —— 类型那一行右边的 「参考文档 ↗」永远指向当前类型的这一页。
- 保存。新建的图标会追加到 dock 末尾,可以按住拖动排序。
状态只存在本机
运行状态、进程、日志、指示器圆点的颜色都只活在这台机器的内存里,
不写进 items.json。所以布局同步(同步密钥)会把配置——
包括指示器的脚本源码——同步到别的机器,但不会因为 A 机开着某个服务,
就让 B 机也显示「运行中」。
网络一律走系统工具
抓网站图标、检查地址是否可达、指示器脚本里的 HTTP,全都由 dock 调系统的
curl.exe 完成。这是理解两件事的关键:指示器脚本里没有
fetch(要用 host.http),以及它不受任何 CORS 限制。
网站
最直接的一类:点一下就在窗口里打开那个地址。
字段
| 名称 | 图标下方的文字,也是窗口标题。 |
| 网址 | 不写 http(s):// 会自动补 https://。只支持 http/https。 |
| 图标 | 留空=自动探测网站的 favicon 并缓存;填了就是钉住这个地址,不再探测。右边的 ↻ 会强制重新读取一次(会覆盖你手填的值)。 |
| 窗口尺寸 | 手机=390×844 的窄视口;小 / 中=屏幕工作区的 1/3、2/3(居中);大=最大化。默认「大」。 |
| 图标底色 | 没有图标可用时显示名称首字母,用这个底色。 |
行为
- 窗口位置和大小按网址记忆:关掉再开,回到你上次放的地方。
- 图标探测的结果按内容寻址缓存在应用数据目录里(
icons.json+icons/<sha>), 所以启动第一帧就有图标,不用等网络。探测不到时会退回「首字母 + 底色」。 - 网站图标没有「日志」可看,所以它的右键菜单只有「打开」「编辑」这类操作。它的网址是固定的, 所以还多出「复制URL」「使用Chrome打开」「使用Edge打开」三项 —— 直接把地址交给系统里的浏览器。
这一类不需要安装任何东西。
本地(Local Run)
点图标 → 在本机跑一条命令 → 从它的输出里认出监听的地址 → 打开那个地址的窗口。 先把服务起起来这件事,dock 帮你做了。
字段
| 启动命令 | 经 cmd.exe /c 执行,例如 npm run dev、python -u -m http.server 8123。 |
| 工作目录 | 命令在这里跑。留空=dock 自己的当前目录。 |
| 端口 | 输出里认不出地址时的第一顺位候选。留空=跳过去扫常见端口。 |
| 兜底地址 | 最后一道:前面都没结果时直接开这个地址。 |
地址是怎么定下来的
按顺序,先命中的赢:
-
输出里最新的一条地址(stdout 和 stderr 都算)。认得出的是这两种写法:
http://…或https://…且能解析成合法地址;- 这一行提到
listen/running/started/ready/serving/server/url/http/local/address/port之一, 并且出现了localhost/127.0.0.1/0.0.0.0/[::1]/::1加端口。这一条是为了让「Listening on 127.0.0.1:5173」 这种不带协议的横幅也能用。
-
端口探测(进程活着、安静了一会儿还没说出地址时,只试一次):
先试你填的端口,再试它 ±1、±2,然后依次试
3000417350005173700080008080850188889000, 第一个能连上的就用。不会扫整段端口。 - 兜底地址:填了就直接用。
认出来的地址如果写的是通配地址(0.0.0.0 或 [::],
vite 和 python 的 http.server 都会这么打印),会被换成一个真正能连的地址 ——
本机跑的就换成 127.0.0.1。通配地址本身不是目的地,直接开它会失败。
窗口什么时候开
点图标时如果服务还没起来,会先启动它、等地址解析出来,再开窗。 地址一旦有人应答(任何 HTTP 状态码都算,包括 404 —— 判断的是「有没有人在听」, 不是「页面正不正常」),就认为可以打开了。
日志
命令的 stdout、stderr 逐行进日志环(每项保留最近 200 行), 右键图标 →「查看日志」能看,编辑器底部也会显示。 「程序在运行,但没能从输出里认出地址」这类提示,答案通常就在这几行里。
管道会让程序缓冲 stdout。
python -m http.server 的横幅是块缓冲的,输出被接到管道时可能一直不出来,
于是永远解析不出地址。加 -u(或设 PYTHONUNBUFFERED=1)即可;
其它语言同理。
子进程的归属
每个图标一棵 job object。dock 退出(包括被杀)时,它的子进程和孙子进程会一起被收拾, 不会留下一个还占着端口的孤儿进程。
这一类不需要安装任何东西:命令走系统 cmd.exe。
远程(Remote Run)
和「本地」同一套逻辑,只是命令通过系统的 ssh.exe 在另一台机器上执行,
地址也是从那边的日志里解析出来的。
字段
| ssh 目标 | [user@]host[:port]。端口要写在 ssh 目标里 —— ssh host:2222 这种写法不是 ssh 的语法,所以会把它拆出来单独传给 ssh。 |
| 启动命令 | 在远端执行,和「本地」一样从输出里认地址。 |
| 端口 / 兜底地址 | 含义同「本地」,只是探测发生在本机(远端那个端口要能连上)。 |
必须先配好密钥
ssh 以 BatchMode 运行:它不会弹密码提示,也不读交互输入。
没配好免密钥登录时,它只会直接失败——日志里是 ssh 自己的 stderr,退出码 255。
Windows 上请先跑一次 ssh user@host 把主机指纹确认掉,再配 authorized_keys。
解析出来的地址是「远端局域网地址」
远端程序如果打印通配地址,会被换成你填的那台 ssh 主机(因为通配在那边意味着「远端的所有网卡」)。 至于那个地址从这台机器过不过得去(同一局域网?VPN?端口放行了吗?), 由你和那台机器决定 —— dock 只负责把地址解析出来并打开。
验证情况说明:本机 sshd 没有密钥时的失败路径已验证(stderr 进日志、tile 变红), 成功路径尚未在本仓库验证过 —— 那需要在真实机器上配好免密钥登录。
依赖系统 ssh.exe(Win10 及以上自带)。
扩展(Extension)
跑一条入口命令,然后打开它起的 web server。命令在一个属于自己的目录里执行; 需要的话,先把一个 git 仓库 clone 到那里再跑。
字段
| git 仓库 | 可选。https 或 ssh 地址,交给系统 git.exe。留空=不 clone,直接在扩展自己的目录里跑入口命令 —— npx -y @scope/pkg 这类应用就是这样,扩展市场里的条目大多是这一种。 |
| 入口命令 | 在扩展目录(填了仓库则是仓库目录)里执行;命令里的
{{dir}} 会替换成那个目录的绝对路径。 |
| 工作目录 | 覆盖执行目录;留空=扩展目录本身(填了仓库时就是仓库目录)。 |
| 端口 / 兜底地址 | 含义同「本地」。 |
目录在哪、什么时候更新
- 每个图标一个目录:
%APPDATA%\app.duiqi.dock\extensions\<图标 id>\。 两个扩展不会互相踩,删掉一个也不影响另一个。 - 填了仓库:第一次点击时 clone;目录已存在时执行
git pull --ff-only—— 用--ff-only是刻意的:这个程序不该替你在仓库里制造一个 merge commit。 - 不自动装依赖。入口命令就是一条完整的命令,需要装就自己写上:
npm i && npm run dev。这样 docker 想跑什么就跑什么,dock 不猜。
填了仓库才依赖系统 git.exe;纯 npx 的条目只依赖 node。
指示器
一段 JavaScript,驱动 dock 上的一个彩色圆点。它不打开任何窗口, 只回答一个问题:现在该是什么颜色。适合盯一个服务是否活着、一个队列积压了多少、 一次构建跑完了没有 —— 任何「扫一眼就知道」的状态。
指示器是五类里唯一需要另装东西的:它要 Node.js。
其余四类走 Windows 自带的 curl.exe / ssh.exe / git.exe。
设置页的「指示器依赖」会告诉你找到的 node 在哪,或者标红说明没装;
找不到 node 时圆点变红,提示是「无法启动」。
脚本是怎么跑起来的
dock 用这样的命令行启动宿主进程,你的脚本在这个进程里求值:
node.exe indicator-host.js --name <图标名称> --cwd <工作目录> --lang <zh|en>
- 整段脚本被包在
(async () => { … })()里,所以顶层await可用。 - 每次保存脚本、或 dock 重新拉起宿主,都会重新加载整段脚本:
上一次绑定的所有 handler 会被清空,圆点回到默认的灰色,旧脚本里还没回来的
await不会影响新脚本。 - 工作目录决定脚本里相对路径的落点,也决定
host.cwd的值。留空时 node 继承 dock 自己的当前目录。 - 宿主进程和 dock 同生共死:dock 退出时它的 stdin 被关闭,宿主跟着退出,不会留下孤儿 node。
脚本里能用什么
只有这些东西,没有别的:
| 名字 | 说明 |
|---|---|
host | 宿主 API,见下。 |
console | log / error / warn / info / debug —— 五个都等同于 host.log。 |
setTimeout clearTimeout setInterval clearInterval | node 的定时器(不属于 JS 语言内建,宿主特意放进来了)。 |
URL | 解析地址用,例如 new URL(u).searchParams.get("q")。 |
TextDecoder | 把字节解成文本。 |
| 语言内建 | Promise JSON Math Date RegExp Map Set Intl、类型化数组等,都照常可用。 |
没有 fetch、require、process、Buffer、
URLSearchParams、TextEncoder、AbortController、
crypto、WebSocket、localStorage。
要访问网络就用 host.http(它是 curl,因此也不受 CORS 限制);
要微任务用 Promise.resolve().then(…)。用错了会看到
ReferenceError: fetch is not defined 这类报错,落在日志里。
这是一个 vm 沙箱,用来避免变量重名,不是安全边界 ——
脚本是你自己写的、在你自己的机器上跑,别把它当隔离手段。
host.* 逐个说
host.name / host.cwd
host.name | 图标的名称。 |
host.cwd | 工作目录字段的值。 |
host.log(...args)
写一行日志。参数之间用空格连接,字符串原样,其它值用 util.inspect 展开
(深度 4)。日志进这个图标的环形缓冲,保留最近 200 行,
右键图标 →「查看日志」或编辑器底部能看到。
host.log("检查完成", { status: 200, ms: 42 });
host.setIndicator({ color, blink, text })
改圆点。三个字段都是可选的,而且是局部合并 —— 只写 text 不会清掉上一次设置的颜色。
| 字段 | 类型 | 说明 |
|---|---|---|
color | 字符串 | 任意 CSS 颜色,例如 "#3fb950"。空字符串会被忽略;写错颜色不会报错,只是画不出来。 |
blink | 布尔 | 为 true 时圆点每秒闪一下(很适合「有东西坏了」)。 |
text | 字符串 | 鼠标停在图标上时显示的一行说明。它不是图标下方的标签 —— 指示器不显示文字。传空字符串可以清掉。 |
圆点默认是灰色 #8b949e。灰色表示「还没报」,不是失败 ——
脚本启动到第一次 setIndicator 之间就是灰的。
host.http.get(url, opts)
发一个 HTTP 请求。这是脚本唯一的网络出口。
| opts | 默认 | 说明 |
|---|---|---|
method | "GET" | 内部转成大写,所以 "post" 也行。 |
headers | — | 一个普通对象:{ "x-token": "…" }。 |
body | — | 字符串,原样发出(不加换行、不改编码)。空字符串=不带 body。 |
timeout | 15 | 秒,整数,范围 1–300。这是整次请求的总时限,不只是连接超时。非数字会落回默认值。 |
返回一个对象:
| 字段 | 说明 |
|---|---|
status | HTTP 状态码,拿不到时是 0。 |
ok | 取值器:200 ≤ status < 300。 |
headers | [[名字, 值], …] 数组,名字已转小写。不是对象,所以要用 filter 找;同名的头(比如两条 set-cookie)会都在,不会被合并。跟随重定向后,这里只有最后一次响应的头。 |
body | 响应体(UTF-8 文本,非法字节会变成 U+FFFD)。 |
text() | 就是 body。 |
json() | JSON.parse(body):body 不是 JSON 时会抛错。 |
底层是 curl,而且固定带这几个开关:跟随重定向(-L)、
不校验证书(-k,为了内网自签名的 https 站点)、一个 Chrome 的 User-Agent。
拿不到响应时,这个 Promise 会 reject(连不上、超时、DNS 失败……),
错误文本是 curl 的 stderr,最多 300 字符。所以请自己 try / catch ——
不接的拒绝会变成日志里的「未处理的 Promise 拒绝」。
响应体上限 4 MB,超了会静默截断(脚本里看不到任何截断标记)。
别用它下载大文件:那是 host.http.stream 或干脆别干的事。
host.http.stream(url, opts, onChunk)
流式读取,适合 SSE、持续输出的日志接口。每收到一块就调一次
onChunk(text),返回的 Promise 在流结束时给出 { status }。
opts 可以省略,直接写 stream(url, onChunk)。
| opts | 默认 | 说明 |
|---|---|---|
headers | — | 同 get:对象。 |
timeout | 300 | 秒,整数,范围 1–3600。 |
和 get 的三点不同,都会咬人:
① 不跟随重定向(没有 -L)—— 地址要对准最终那个;
② 数据块不是按行切的,一次读 8 KB,可能把一行、甚至把一个多字节汉字切成两半
(半截字节会永久变成乱码)—— 所以请自己拼缓冲、按 \n 切,最后剩下的半行留到下一块;
③ 连不上时只给 status: 0,没有错误文本。
host.request(url | { url, name })
让 dock 开一个窗口,最常写在 onClick 里。
name 是那个窗口的标题,不写就用 host.name。
host.onClick(() => host.request({ url: "https://example.com/panel", name: "面板" }));
这个调用总是立即成功:dock 收下请求就回 OK,窗口能否打开不会回传。
所以别指望用它判断「到底开没开」—— 需要确认的话,自己 host.http.get 探一下。
四种事件
| 绑定 | 什么时候跑 | 能 await 吗 |
|---|---|---|
host.onStart(fn) |
脚本加载完,跑一次。适合做一次性的准备工作。 | 能。fn 返回的 Promise 会被等到。 |
host.onTick(fn) |
每 5 秒一次,第一次在启动约 5 秒后。 | 能,但不重入:上一次还在 await 时,这次直接跳过。所以一个永远不结束的 tick 会让后续 tick 永久停摆。 |
host.onClick(fn) |
点这个图标时。 | 能。 |
host.onStop(fn) |
宿主被要求停止时(dock 退出、图标被删、配置改了导致重启)。 | 基本不行:只有约 80 毫秒的收尾时间,之后进程就被杀了。别在这里等网络。 |
每个事件可以绑多个 handler(按绑定顺序跑)。没绑 onClick 时,
点击图标什么都不会发生 —— 这不是错误,只是「这个指示器没有可点的东西」。
handler 里抛出的异常不会中断任何东西,只会往日志里写一行
onTick 抛错:…(各事件同理,并把错误信息截到 400 字符)。
出错了会怎样
| 情况 | 你会看到 |
|---|---|
| 脚本文本有语法错误 | 日志里 脚本语法错误:…,圆点变红,提示「脚本错误」。而且不会自动重试 —— 改好脚本保存,或重启 dock,才会重新加载。 |
| 脚本运行中抛异常(或顶层 await 被拒绝) | 日志里 脚本报错:…,圆点变红,提示「脚本报错」。这只影响这一次;onTick 之类的 handler 之后照常被调用。 |
| 某次 tick / 点击里出了错 | 日志一行,圆点不动。 |
| node 进程意外退出 | 日志 node 已退出(代码 N),然后等 5 秒自动重启。脚本每次启动都崩的话,就是「每 5 秒一轮」的循环 —— 看日志能认出来。 |
| 脚本是空的 | 灰点,提示「未配置」,连 node 都不会启动。 |
| 这台机器没有 node | 红点,提示「无法启动」,日志里写清楚要装 Node.js。 |
从最简单的开始
把这个贴进「脚本」,保存。每 5 秒它会请求一次那个地址,通就是绿的,不通就是红的:
host.onTick(async () => {
const r = await host.http.get("http://127.0.0.1:8123/", { timeout: 5 });
host.setIndicator({ color: r.ok ? "#3fb950" : "#f85149" });
});
再进一步:连不上时区分「服务坏了」和「服务没了」,连续失败就让它闪。
let fails = 0;
host.onTick(async () => {
let color = "#8b949e", text = "检查中";
try {
const r = await host.http.get("http://127.0.0.1:8123/", { timeout: 5 });
if (r.ok) {
fails = 0;
color = "#3fb950";
text = "正常 · HTTP " + r.status;
} else {
color = "#d29922"; // 有人应答,但答案不对
text = "异常 · HTTP " + r.status;
}
} catch (e) {
fails += 1;
color = "#f85149";
text = "连不上(连续 " + fails + " 次)";
host.log("健康检查失败:", e.message);
}
host.setIndicator({ color, blink: fails >= 3, text });
});
几个常用的写法
把接口里的一个数字放到提示里(颜色沿用上一次,不用重复设):
host.onTick(async () => {
const r = await host.http.get("https://example.com/api/queue");
host.setIndicator({ text: "队列积压 " + r.json().pending + " 条" });
});
点击打开一个页面:
host.onClick(() => host.request({ url: "https://example.com/dashboard", name: "面板" }));
读响应头(注意是数组,不是对象):
host.onStart(async () => {
const r = await host.http.get("https://example.com/login");
const cookies = r.headers
.filter(function (h) { return h[0] === "set-cookie"; })
.map(function (h) { return h[1]; });
host.log("拿到 " + cookies.length + " 条 Set-Cookie");
});
带请求头和 body 发一次 POST:
const r = await host.http.get("https://example.com/api/state", {
method: "post", // 大小写都行
headers: { "content-type": "application/json" }, // 请求头是对象
body: JSON.stringify({ hello: "world" }), // 原样发出,不加换行
timeout: 30,
});
跟着一个日志流跑(自己拼行,别假设一块就是一行):
let buf = "";
host.onStart(async () => {
const r = await host.http.stream(
"http://127.0.0.1:8123/log",
{ timeout: 3600 },
function (chunk) {
buf += chunk;
const lines = buf.split("\n");
buf = lines.pop(); // 最后一段可能只有半行,留到下一块
for (const line of lines) {
if (line.indexOf("ERROR") >= 0)
host.setIndicator({ color: "#f85149", blink: true, text: line });
}
}
);
host.log("流结束,status =", r.status);
});
限制速查
| tick 间隔 | 5 秒,不重入 |
http.get 超时 | 1–300 秒,默认 15 |
http.stream 超时 | 1–3600 秒,默认 300 |
| 响应体上限 | 4 MB(超出静默截断) |
| 日志保留 | 每项最近 200 行 |
onStop 收尾时间 | 约 80 毫秒 |
| 宿主崩溃后的重启延迟 | 5 秒 |
编辑器里的「测试运行」
它用同一个宿主把当前编辑框里的脚本跑一遍,最多收集 6 秒的输出,
把 host.log 的内容、错误、以及最后算出的颜色显示在编辑器里 ——
不用保存就能试。两点要知道:
- 它只执行脚本体和
onStart,不发 tick、也不模拟点击。 所以逻辑写在onTick里的脚本,测试运行会显示灰点—— 那只是「还没报」,不是失败。 - 它会真的发网络请求(用的是编辑框里的脚本,不是已保存的版本)。
这台机器没有 node 时,「测试运行」是灰的,旁边会写明原因。
指示器需要 Node.js;其余四类用系统自带的 curl.exe / ssh.exe / git.exe。
AI Agent 连接
这一页讲的不是图标类型,而是让本机的 AI Agent 来管这个停靠栏。
DuiqiDock 在启动时开一个只监听 127.0.0.1 的小接口,
并自带一份技能(Skill):一段说明加一个命令行客户端。
把技能装进 Agent 的技能目录之后,它用自己的命令行工具就能读和改停靠栏,
不需要你去读源码、也不需要给它贴一堆命令。
怎么用起来
- 打开设置 → AI Agent。
- 左栏列出本机的 Agent,以及每个的状态(待安装 / 已装 Skill / 需更新 / 未检测到)。
- 选中一个,点安装。技能被写到它自己的技能目录里,只碰
<技能目录>\duiqidock\这一个子目录。 - 回到那个 Agent 里直接说话,比如「往停靠栏加一个内部看板」。
| Agent | 技能装到哪 |
|---|---|
| Claude Code | %USERPROFILE%\.claude\skills\duiqidock\ |
| WorkBuddy | %USERPROFILE%\.workbuddy\skills\duiqidock\ |
| Pi Agent | %USERPROFILE%\.pi\agent\skills\duiqidock\ |
| Hermes | %USERPROFILE%\.hermes\skills\duiqidock\ |
| DeepSeek Harness | %USERPROFILE%\.dsh\skills\duiqidock\ |
| (共用目录) | %USERPROFILE%\.agents\skills\duiqidock\ —— Pi 与 DeepSeek Harness 都会读这里 |
这些目录都可以用环境变量挪走(CLAUDE_CONFIG_DIR、
WORKBUDDY_CONFIG_DIR、PI_CODING_AGENT_DIR、
HERMES_HOME、DSH_HOME):设了就以环境变量为准。
检测不到某个 Agent 时,那一行照样列出来,写明原因(比如「.hermes 不存在」),
安装按钮是灰的。
Agent 能做什么
| 能做 | 说明 |
|---|---|
| 看 | 图标列表与单个图标的全部字段、设置项、每个图标的运行状态与最近日志。 |
| 改图标 | 新建 / 修改 / 删除 / 排序 —— 走的是和界面完全相同的那条路,所以改完停靠栏立刻就变。 |
| 管进程 | 启动 / 停止 / 重启 / 打开图标背后的程序,或者点一下指示器。 |
| 改设置 | 图标大小、主题、是否显示名称、询问保存位置、服务器地址。 |
| 装扩展 | 列出官方扩展市场的条目,并把某一条装到停靠栏上。 |
| 撤销 | 看最近的修改记录(谁改的、改了什么),退回上一步。 |
改错了能退回去
图标列表的每一次改动都会先存一份改动前的完整快照,最近 50 步, 记在内存里(不落盘)。设置页那一栏的「撤销上一步」就是退回去, Agent 自己也能调。两件要知道的事:
- 撤销一次删除会把图标放回停靠栏,但那个程序 不会自己再跑起来 —— 要跑得再说一句启动。
- 反过来,撤销一次新增会把那个图标连同 它正在跑的进程和窗口一起收掉。图标都没了,留着就是没人管的 孤儿进程,所以「加错了」直接撤掉是干净的,不用自己去杀进程。
- 撤销是一次真正的本地改动,会跟着同步推到别的机器上。
这个通道的边界
- 只在本机:接口只绑
127.0.0.1的随机端口,端口和令牌每次启动重新生成, 只有本机进程连得上。想关掉就在设置页把开关关掉(关掉后不再监听,发现文件也删掉)。 - 凭据是账号密钥,不给 Agent:读设置时「同步密钥」一律是空的,写进去也会被忽略。
- 「装上」不等于「生效」:安装只保证文件在正确的目录、内容是对的。 Agent 什么时候读它,是那个 Agent 自己的事 —— 大多数 Agent 在下次启动时才扫描技能目录。
- 同名技能不覆盖:如果技能目录里已经有一个不是本程序装的
duiqidock, 两边都会拒绝操作并在页面上说明,需要你自己先处理掉那个目录。
Agent 的每一次图标改动都会记在「最近的修改」里,能看到是它改的还是你改的。