DuiqiDock Manual
Five kinds of icon · indicator script syntax · the AI Agent channel
What an icon is
Every icon on DuiqiDock is a component: it is defined by a kind plus that kind's settings, and it answers three questions for itself — which address to open, what to draw, and what state it is in right now. The kind decides which fields you have to fill in.
| Kind | What you fill in | What it opens | Requires |
|---|---|---|---|
| Website | URL | That URL | Nothing (the icon is fetched with the system's own curl.exe) |
| Local | Start command, working directory, port | Runs a process here → recognises the listening address from its output, and falls back to the port you filled in | Nothing |
| Remote | ssh target, start command, port | Runs the process over ssh on that machine → parses the address out of its log |
The system's ssh.exe (shipped with Win10+) |
| Extension | Entry command, working directory, git repo (optional) | (clones the repo into the app data directory first, if you gave one) → runs the entry command → the web server it starts | The system's git.exe (only used when you gave a repo) |
| Indicator | A piece of JS | Nothing at all: a coloured dot that can blink | Node.js (the only one of the five that needs something installed) |
Adding an icon
- Click the + under the dock (it stays on the last row once you have many icons).
- Choose the kind in the editor and fill in that kind's fields — the "Reference docs ↗" link on the kind's row always points at this kind's page.
- Save. A new icon is appended to the end of the dock; hold and drag to reorder.
State lives only on this machine
Running state, processes, logs and the indicator dot's colour live in this
machine's memory only and are never written to items.json.
So layout sync (sync keys) moves the configuration — including an indicator's
script source — to another machine, but it does not make
machine B show "running" because machine A has the service up.
Network access always goes through system tools
Fetching a site's icon, checking whether an address answers, and HTTP from an
indicator script are all done by the dock calling the system's
curl.exe. That is the key to two things: an indicator script has
no fetch (use host.http), and it is
subject to no CORS restrictions whatsoever.
Website
The most direct kind: click it and that address opens in a window.
Fields
| Name | The text under the icon, and the window's title. |
| URL | Without http(s):// it is completed to https://. Only http/https is supported. |
| Icon | Empty = detect the site's favicon automatically and cache it; filled in = pin this address and stop detecting. The ↻ next to it re-reads it once, by force (which overwrites what you typed). |
| Window size | Phone = a 390×844 narrow viewport; Small / Medium = 1/3 and 2/3 of the screen's work area (centred); Large = maximised. The default is Large. |
| Icon tint | When there is no icon to draw, the name's first letter is drawn on this colour. |
Behaviour
- The window's position and size are remembered per URL: close it and open it again and it comes back where you left it.
- What came back from icon detection is cached content-addressed in the app data directory
(
icons.json+icons/<sha>), so the first frame after a start has the icon without waiting for the network. When nothing is found it falls back to "first letter + tint". - A website icon has no log to look at, so its right-click menu holds only actions like Open and Edit. Its address is fixed, so it also offers Copy URL, Open in Chrome and Open in Edge — the address is handed to a browser installed on this machine.
Nothing needs installing for this kind.
Local (Local Run)
Click the icon → a command runs on this machine → the listening address is recognised from its output → a window opens at that address. Getting the service up is the part the dock does for you.
Fields
| Start command | Run through cmd.exe /c, for example npm run dev or python -u -m http.server 8123. |
| Working directory | The command runs here. Empty = the dock's own current directory. |
| Port | The first candidate when no address can be read out of the output. Empty = skip ahead to the common-port scan. |
| Fallback URL | The last resort: open this address when nothing above produced a result. |
How the address is decided
In order; the first hit wins:
-
The newest address in the output (stdout and stderr both count).
Two shapes are recognised:
http://…orhttps://…that parses as a valid address;- a line that mentions one of
listen/running/started/ready/serving/server/url/http/local/address/portand containslocalhost/127.0.0.1/0.0.0.0/[::1]/::1with a port. This second one exists so that a bare banner like "Listening on 127.0.0.1:5173" works too.
-
Port probing (once only, when the process is alive and has gone quiet without
naming an address): first the port you filled in, then ±1 and ±2 around it, then
3000417350005173700080008080850188889000in order, and the first one that connects is used. No sweeping a whole range. - Fallback URL: if you filled one in, it is used as-is.
An address that comes out as a wildcard (0.0.0.0 or [::], which is how
vite and Python's http.server print it) is replaced with one that can actually be connected to —
for a local run, 127.0.0.1. A wildcard is not a destination; opening it fails.
When the window opens
If the service is not up when you click the icon, it is started first, the address is waited for, and then the window opens. As soon as something answers at that address it counts as ready — any HTTP status code, 404 included, because the question being asked is "is anything listening", not "is the page correct".
Logs
The command's stdout and stderr go into a log ring line by line (the last 200 lines are kept per icon). Right-click the icon → "View Log" shows it, and the bottom of the editor shows it too. Messages like "the program is running, but no address could be recognised in its output" usually have their answer in these lines.
A pipe makes a program buffer stdout.
The banner python -m http.server prints is block-buffered, and when its output
is a pipe it may never appear — so no address is ever parsed. Add -u (or set
PYTHONUNBUFFERED=1); the same goes for other languages.
Which process belongs to whom
One job object per icon. When the dock exits — being killed included — that icon's child and grandchild processes are cleaned up with it, so no orphan is left holding a port.
Nothing needs installing for this kind: commands go through the system's cmd.exe.
Remote (Remote Run)
The same logic as Local, except the command runs on another machine through the system's
ssh.exe, and the address is parsed out of that machine's log.
Fields
| ssh target | [user@]host[:port]. The port has to be written inside the ssh target — ssh host:2222 is not ssh's own syntax, so it is split out here and passed to ssh separately. |
| Start command | Executed on the far side, with the address read out of its output exactly as in Local. |
| Port / Fallback URL | Same meaning as Local, except the probe happens on this machine (the remote port has to be reachable). |
Keys have to be set up first
ssh runs in BatchMode: it will not prompt for a password and
reads no interactive input. Without passwordless login set up it simply fails — the log holds
ssh's own stderr and the exit code is 255. On Windows, run ssh user@host once by
hand to confirm the host fingerprint, then set up authorized_keys.
The parsed address is a "remote LAN address"
If the remote program prints a wildcard, it is replaced with the ssh host you filled in (a wildcard over there means "every interface on that machine"). Whether that address is reachable from here — same LAN? VPN? Is the port let through? — is between you and that machine. The dock's job ends at parsing the address and opening it.
A note on how far this has been verified: the failure path with no key configured for the local sshd has been verified (stderr reaches the log, the tile turns red); the success path has not been verified anywhere in this repository — that needs passwordless login set up against a real machine.
Requires the system's ssh.exe (shipped with Windows 10 and later).
Extension
Run an entry command, then open the web server it starts. The command runs in a directory of its own; if you need one, a git repository is cloned there first.
Fields
| git repo | Optional. An https or ssh address, handed to the system's git.exe.Empty = no clone, the entry command runs directly in the extension's own directory — which is what npx -y @scope/pkg-style apps want, and is what most entries
in the extension market are. |
| Entry command | Run in the extension directory (or in the repository directory when you gave a repo);
{{dir}} in the command is replaced with that directory's absolute path. |
| Working directory | Overrides the execution directory; empty = the extension directory itself (the repository directory when you gave a repo). |
| Port / Fallback URL | Same meaning as Local. |
Where the directory is, and when it updates
- One directory per icon:
%APPDATA%\app.duiqi.dock\extensions\<icon id>\. Two extensions cannot step on each other, and deleting one leaves the other alone. - With a repo: cloned on the first click; when the directory already exists,
git pull --ff-onlyis run —--ff-onlyon purpose, because this program has no business inventing a merge commit in your repository. - Dependencies are never installed for you. The entry command is a complete
command; if something has to be installed, say so in it:
npm i && npm run dev. That way it can run whatever it likes — the dock does not guess.
It needs the system's git.exe only when you gave a repo; a pure npx entry needs only node.
Indicator
A piece of JavaScript that drives one coloured dot on the dock. It opens no window and answers exactly one question: what colour should this be right now. Good for watching whether a service is alive, how far a queue has backed up, whether a build has finished — any state you want to know at a glance.
An indicator is the one kind of the five that needs something installed: Node.js.
The other four use the curl.exe / ssh.exe / git.exe that
Windows already has. Settings → Indicator dependencies tells you where the node it found
lives, or marks itself red to say there is none; with no node the dot turns red with the hint
"Cannot start".
How a script runs
The dock starts the host process with this command line, and your script is evaluated inside it:
node.exe indicator-host.js --name <icon name> --cwd <working directory> --lang <zh|en>
- The whole script is wrapped in
(async () => { … })(), so top-levelawaitworks. - Saving the script, or the dock bringing the host back up, reloads the whole
script: every handler bound before is dropped, the dot goes back to the default
grey, and an
awaitin the old script that never returned cannot affect the new one. - The working directory decides where a relative path inside the script lands, and it is the
value of
host.cwd. Left empty, node inherits the dock's own current directory. - The host process lives and dies with the dock: when the dock exits its stdin is closed, the host follows it out, and no orphan node is left behind.
What a script can see
These names, and nothing else:
| Name | What it is |
|---|---|
host | The host API — see below. |
console | log / error / warn / info / debug — all five are host.log. |
setTimeout clearTimeout setInterval clearInterval | node's timers (not part of the language; the host puts them in deliberately). |
URL | For parsing addresses, e.g. new URL(u).searchParams.get("q"). |
TextDecoder | For turning bytes into text. |
| Language built-ins | Promise JSON Math Date RegExp Map Set Intl, typed arrays and so on all work as usual. |
There is no fetch, require, process,
Buffer, URLSearchParams, TextEncoder,
AbortController, crypto, WebSocket or
localStorage. To reach the network use host.http (it is curl, so it is
under no CORS rule either); for a microtask use Promise.resolve().then(…). Using one
of the missing ones shows up in the log as ReferenceError: fetch is not defined and
the like.
This is a vm sandbox, there to keep variable names from colliding,
not as a security boundary — the script is your own, running on your own machine, and it is not
a way to isolate anything.
host.*, one by one
host.name / host.cwd
host.name | The icon's name. |
host.cwd | The value of the working directory field. |
host.log(...args)
Write one log line. Arguments are joined with spaces; strings go in as they are and everything
else is expanded with util.inspect (depth 4). Lines go into this icon's ring
buffer, which keeps the last 200 lines, and are visible in right-click →
"View Log" or at the bottom of the editor.
host.log("check finished", { status: 200, ms: 42 });
host.setIndicator({ color, blink, text })
Change the dot. All three fields are optional and are merged in — passing only text does not clear the colour set last time.
| Field | Type | What it does |
|---|---|---|
color | string | Any CSS colour, e.g. "#3fb950". An empty string is ignored; a nonsense colour does not throw, it simply draws nothing. |
blink | boolean | When true, the dot blinks once a second (good for "something is broken"). |
text | string | One line shown when the pointer rests on the icon. It is not a label under the icon — an indicator shows no text. Pass an empty string to clear it. |
The dot is grey (#8b949e) to begin with. Grey means "nothing reported
yet", not "failed" — that is what it is between the script starting and the first
setIndicator.
host.http.get(url, opts)
Make one HTTP request. This is the script's only way out to the network.
| opts | Default | What it does |
|---|---|---|
method | "GET" | Upper-cased internally, so "post" is fine too. |
headers | — | A plain object: { "x-token": "…" }. |
body | — | A string, sent as-is (no newline added, no re-encoding). An empty string means no body. |
timeout | 15 | Seconds, integer, 1–300. This is the whole request's budget, not just the connect. A non-number falls back to the default. |
It resolves to an object:
| Field | What it is |
|---|---|
status | The HTTP status code, or 0 when there is none. |
ok | A getter: 200 ≤ status < 300. |
headers | An array of [[name, value], …] with names lower-cased. Not an object, so find in it with filter; repeated headers (two set-cookie lines, say) are all there and are not merged. After redirects are followed, only the final response's headers are here. |
body | The response body (UTF-8 text; invalid bytes become U+FFFD). |
text() | Simply body. |
json() | JSON.parse(body): it throws when the body is not JSON. |
Underneath it is curl, always with these switches: follow redirects
(-L), do not verify the certificate (-k, for
self-signed https on an internal network), and a Chrome User-Agent.
When no response can be had, this Promise rejects (cannot connect, timed
out, DNS failure…), and the error text is curl's stderr, at most 300 characters. So
try / catch it yourself — an unhandled rejection shows up in the log as
Unhandled promise rejection (the host's line; see the note under "When
something goes wrong").
The response body is capped at 4 MB and silently truncated beyond it (there
is no truncation marker in the script). Do not use this to download large files: that is
host.http.stream's job, or better, not this tool's job at all.
host.http.stream(url, opts, onChunk)
Read a response in chunks — for SSE and for log endpoints that keep printing. Each chunk
calls onChunk(text) once, and the returned Promise gives
{ status } when the stream ends. opts may be left out, so
stream(url, onChunk) works.
| opts | Default | What it does |
|---|---|---|
headers | — | As in get: an object. |
timeout | 300 | Seconds, integer, 1–3600. |
Three ways it differs from get, all of which bite:
① Redirects are not followed (no -L) — point the address at
the final URL;
② chunks are not cut on line boundaries: it reads 8 KB at a time and can
split a line, or a multi-byte character, down the middle (a half character stays mangled
forever) — so build the buffer yourself, split on \n, and carry the trailing
partial line over to the next chunk;
③ when it cannot connect you get only status: 0 — no error text.
host.request(url | { url, name })
Ask the dock to open a window; most often written inside onClick.
name is that window's title and defaults to host.name.
host.onClick(() => host.request({ url: "https://example.com/panel", name: "Panel" }));
This call always succeeds immediately: the dock takes the request and says
OK, and whether a window actually opened is never reported back. So do not use it to decide
"did it open" — if you need to know, probe with host.http.get yourself.
The four events
| Binding | When it runs | Can it await? |
|---|---|---|
host.onStart(fn) |
Once, after the script has loaded. The place for one-off preparation. | Yes. The Promise fn returns is waited for. |
host.onTick(fn) |
Every 5 seconds, the first one about 5 seconds after start. | Yes, but it does not re-enter: if the previous tick is still awaiting, this one is skipped. A tick that never finishes stops every later tick for good. |
host.onClick(fn) |
When this icon is clicked. | Yes. |
host.onStop(fn) |
When the host is asked to stop (the dock exiting, the icon being deleted, a settings change that restarts it). | Mostly no: there are about 80 milliseconds to finish, and then the process is killed. Do not wait on the network here. |
Each event can have several handlers bound (they run in binding order). With no
onClick bound, clicking the icon does nothing at all — that is not an error,
it just means this indicator has nothing to click. An exception thrown inside a handler
interrupts nothing; it writes one line into the log, onTick threw: … (same for
every event, with the message cut to 400 characters).
When something goes wrong
| Situation | What you see |
|---|---|
| The script text has a syntax error | Script syntax error: … in the log, a red dot, the hint "Script error". And it is not retried automatically — fix the script and save, or restart the dock, and it loads again. |
| The script throws while running (or its top-level await rejects) | Script error: … in the log, a red dot, the hint "Script error". This affects this one run only; handlers like onTick keep being called afterwards. |
| An error inside one tick or one click | One log line, and the dot does not move. |
| The node process exits unexpectedly | node exited (code N) in the log, and then it restarts itself after 5 seconds. A script that crashes on every start is a "once every 5 seconds" loop — the log makes that obvious. |
| The script is empty | A grey dot, the hint "Not configured", and node is not even started. |
| There is no node on this machine | A red dot, the hint "Cannot start", and a log line saying plainly that Node.js has to be installed. |
Everything above is the app's own text in the app's own language — including the four lines the
host process writes (Script syntax error:, Script error:,
onXxx threw:, Unhandled promise rejection:), because DuiqiDock tells
the host which language to use when it starts it. That is the one place where changing the
language is not instant: a host that is already running keeps the language it started in until
it reloads. And what your own script logs with host.log is your text, passed
through exactly as you wrote it.
Starting from the simplest thing
Paste this into "Script" and save. Every 5 seconds it asks that address; a reply means green, no reply means red:
host.onTick(async () => {
const r = await host.http.get("http://127.0.0.1:8123/", { timeout: 5 });
host.setIndicator({ color: r.ok ? "#3fb950" : "#f85149" });
});
One step further: tell "the service is broken" from "the service is gone", and blink after a run of failures.
let fails = 0;
host.onTick(async () => {
let color = "#8b949e", text = "checking";
try {
const r = await host.http.get("http://127.0.0.1:8123/", { timeout: 5 });
if (r.ok) {
fails = 0;
color = "#3fb950";
text = "ok · HTTP " + r.status;
} else {
color = "#d29922"; // something answered, but not with an OK
text = "bad · HTTP " + r.status;
}
} catch (e) {
fails += 1;
color = "#f85149";
text = "unreachable (" + fails + " in a row)";
host.log("health check failed:", e.message);
}
host.setIndicator({ color, blink: fails >= 3, text });
});
A few shapes worth keeping
Put a number from an API into the tooltip (the colour is left as it was, so it need not be set again):
host.onTick(async () => {
const r = await host.http.get("https://example.com/api/queue");
host.setIndicator({ text: "backlog: " + r.json().pending });
});
Open a page on click:
host.onClick(() => host.request({ url: "https://example.com/dashboard", name: "Panel" }));
Read a response header (note that it is an array, not an object):
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("got " + cookies.length + " Set-Cookie headers");
});
Send one POST with headers and a body:
const r = await host.http.get("https://example.com/api/state", {
method: "post", // case does not matter
headers: { "content-type": "application/json" }, // headers are an object
body: JSON.stringify({ hello: "world" }), // sent as-is, no newline added
timeout: 30,
});
Follow a log stream (assemble the lines yourself; never assume a chunk is a line):
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(); // the tail may be half a line; keep it for the next chunk
for (const line of lines) {
if (line.indexOf("ERROR") >= 0)
host.setIndicator({ color: "#f85149", blink: true, text: line });
}
}
);
host.log("stream ended, status =", r.status);
});
Limits at a glance
| tick interval | 5 seconds, no re-entry |
http.get timeout | 1–300 seconds, 15 by default |
http.stream timeout | 1–3600 seconds, 300 by default |
| response body cap | 4 MB (silently truncated beyond it) |
| log kept | the last 200 lines per icon |
onStop time to finish | about 80 milliseconds |
| delay before a crashed host comes back | 5 seconds |
"Test run" in the editor
It runs the script in the edit box through the same host, collects output for
up to 6 seconds, and shows host.log's lines, any error, and the resulting colour
in the editor — so you can try something without saving. Two things to know:
- It runs the script body and
onStartonly: no ticks, and no simulated clicks. So a script whose logic is all inonTickshows a grey dot in a test run — that is "nothing reported yet", not a failure. - It really does make network requests (using what is in the edit box, not the saved version).
With no node on this machine, "Test run" is greyed out and the reason is written next to it.
An indicator needs Node.js; the other four kinds use the curl.exe / ssh.exe / git.exe that Windows already has.
AI Agent connection
This page is not about an icon kind: it is about letting an AI Agent on this machine
drive the dock. On start, DuiqiDock opens a small interface bound to 127.0.0.1
only, and ships a skill: a description plus a command-line client. Once the
skill is in an Agent's skills directory, that Agent can read and change the dock with its own
command-line tool — no reading this source, no pasting it a pile of commands.
Getting it working
- Open Settings → AI Agent.
- The list on the left is the Agents found on this machine, each with its state (not installed / skill installed / needs update / not detected).
- Pick one and click Install. The skill is written into that Agent's own
skills directory, touching only the one subdirectory
<skills directory>\duiqidock\. - Go back to that Agent and just talk to it, e.g. "add an internal dashboard to the dock".
| Agent | Where the skill goes |
|---|---|
| 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\ |
| (a shared directory) | %USERPROFILE%\.agents\skills\duiqidock\ — both Pi and DeepSeek Harness read this one |
All of these can be moved with an environment variable (CLAUDE_CONFIG_DIR,
WORKBUDDY_CONFIG_DIR, PI_CODING_AGENT_DIR, HERMES_HOME,
DSH_HOME): when one is set, it wins. An Agent that cannot be found is still listed,
with the reason next to it (e.g. ".hermes does not exist"), and its Install button
is greyed out.
What an Agent can do
| It can | Meaning |
|---|---|
| Look | The icon list and every field of one icon, the settings, and each icon's running state and recent log. |
| Change icons | Add / edit / delete / reorder — through exactly the same path the UI uses, so the dock changes the moment it is done. |
| Manage processes | Start / stop / restart / open the program behind an icon, or click an indicator. |
| Change settings | Icon size, theme, whether names are shown, asking where to save, the server address. |
| Install extensions | List the official extension market and put one of its entries on the dock. |
| Undo | See the recent changes (who made them, what changed) and step back one. |
Undoing a mistake
Every change to the icon list first stores a complete snapshot of what it looked like before, the last 50 steps, in memory (nothing is written to disk). "Undo last change" in that section of the settings page steps back, and an Agent can call it too. Two things to know:
- Undoing a delete puts the icon back on the dock, but the program behind it does not start running again by itself — ask for a start if you want it up.
- The other way round, undoing an add takes the icon away together with the process and windows it is running. With the icon gone, what is left would be an unowned orphan process, so undoing a mistaken add is clean — there is no process to kill by hand.
- An undo is a real local change, and it is pushed to your other machines by sync like any other.
Where this channel stops
- This machine only: the interface binds a random port on
127.0.0.1, and both port and token are generated again on every start, so only local processes can reach it. To turn it off, use the switch in the settings page (it stops listening and deletes the discovery file). - The credential is an account key and is not given to the Agent: reading the settings always shows an empty sync key, and writing one is ignored.
- "Installed" is not "in effect": installing only guarantees the files are in the right directory with the right contents. When an Agent reads it is up to that Agent — most scan their skills directory at the next start.
- A skill of the same name is not overwritten: if the skills directory already
has a
duiqidockthis program did not install, both sides refuse and the page says so; that directory is yours to deal with first.
Every icon change an Agent makes is recorded in "recent changes", so you can see whether it was the Agent or you.