DuiqiDock 使用文档

五类图标 · 指示器脚本语法 · AI Agent 通道

图标是什么

DuiqiDock 上每个图标都是一个组件:由「类型 + 该类型的配置」定义, 自己回答三个问题 —— 要打开哪个地址、画什么图标、 现在是什么状态。类型决定了后面要填哪些字段。

类型填什么打开什么依赖
网站 网址 该网址 无(图标用系统自带 curl.exe 抓)
本地 启动命令、工作目录、端口 本机起进程 → 从它的输出里认出监听地址,认不出就试你填的端口 无
远程 ssh 目标、启动命令、端口 ssh 在那台机器上起进程 → 从它的日志里解析出地址 系统 ssh.exe(Win10+ 自带)
扩展 入口命令、工作目录、git 仓库(可选) (填了仓库就先 clone 到应用数据目录)→ 跑入口 → 它起的 web server 系统 git.exe(只有填了仓库才用得上)
指示器 一段 JS 不打开任何东西:一个会闪的彩色圆点 Node.js(五类里唯一需要另装的)

怎么加一个图标

  1. 点 dock 下方的 +(图标多的时候它还是最后一行)。
  2. 在编辑器里选类型,填该类型的字段 —— 类型那一行右边的 「参考文档 ↗」永远指向当前类型的这一页。
  3. 保存。新建的图标会追加到 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 自己的当前目录。
端口输出里认不出地址时的第一顺位候选。留空=跳过去扫常见端口。
兜底地址最后一道:前面都没结果时直接开这个地址。

地址是怎么定下来的

按顺序,先命中的赢:

  1. 输出里最新的一条地址(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」 这种不带协议的横幅也能用。
    服务重启会打印新横幅,所以取最新的一条:那是当前有效的地址。
  2. 端口探测(进程活着、安静了一会儿还没说出地址时,只试一次): 先试你填的端口,再试它 ±1、±2,然后依次试 3000 4173 5000 5173 7000 8000 8080 8501 8888 9000, 第一个能连上的就用。不会扫整段端口。
  3. 兜底地址:填了就直接用。

认出来的地址如果写的是通配地址(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,见下。
consolelog / error / warn / info / debug —— 五个都等同于 host.log。
setTimeout clearTimeout setInterval clearIntervalnode 的定时器(不属于 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。
timeout15秒,整数,范围 1–300。这是整次请求的总时限,不只是连接超时。非数字会落回默认值。

返回一个对象:

字段说明
statusHTTP 状态码,拿不到时是 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:对象。
timeout300秒,整数,范围 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 的技能目录之后,它用自己的命令行工具就能读和改停靠栏, 不需要你去读源码、也不需要给它贴一堆命令。

怎么用起来

  1. 打开设置 → AI Agent。
  2. 左栏列出本机的 Agent,以及每个的状态(待安装 / 已装 Skill / 需更新 / 未检测到)。
  3. 选中一个,点安装。技能被写到它自己的技能目录里,只碰 <技能目录>\duiqidock\ 这一个子目录。
  4. 回到那个 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 的每一次图标改动都会记在「最近的修改」里,能看到是它改的还是你改的。