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.

KindWhat you fill inWhat it opensRequires
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

  1. Click the + under the dock (it stays on the last row once you have many icons).
  2. 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.
  3. 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

NameThe text under the icon, and the window's title.
URLWithout http(s):// it is completed to https://. Only http/https is supported.
IconEmpty = 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 sizePhone = 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 tintWhen 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 commandRun through cmd.exe /c, for example npm run dev or python -u -m http.server 8123.
Working directoryThe command runs here. Empty = the dock's own current directory.
PortThe first candidate when no address can be read out of the output. Empty = skip ahead to the common-port scan.
Fallback URLThe last resort: open this address when nothing above produced a result.

How the address is decided

In order; the first hit wins:

  1. The newest address in the output (stdout and stderr both count). Two shapes are recognised:
    • http://… or https://… that parses as a valid address;
    • a line that mentions one of listen / running / started / ready / serving / server / url / http / local / address / port and contains localhost / 127.0.0.1 / 0.0.0.0 / [::1] / ::1 with a port. This second one exists so that a bare banner like "Listening on 127.0.0.1:5173" works too.
    Restarting a service prints a new banner, so the newest one is taken: that is the address that is live.
  2. 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 3000 4173 5000 5173 7000 8000 8080 8501 8888 9000 in order, and the first one that connects is used. No sweeping a whole range.
  3. 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 commandExecuted on the far side, with the address read out of its output exactly as in Local.
Port / Fallback URLSame 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 repoOptional. 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 commandRun 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 directoryOverrides the execution directory; empty = the extension directory itself (the repository directory when you gave a repo).
Port / Fallback URLSame 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-only is run — --ff-only on 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-level await works.
  • 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 await in 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:

NameWhat it is
hostThe host API — see below.
consolelog / error / warn / info / debug — all five are host.log.
setTimeout clearTimeout setInterval clearIntervalnode's timers (not part of the language; the host puts them in deliberately).
URLFor parsing addresses, e.g. new URL(u).searchParams.get("q").
TextDecoderFor turning bytes into text.
Language built-insPromise 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.nameThe icon's name.
host.cwdThe 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.

FieldTypeWhat it does
colorstringAny CSS colour, e.g. "#3fb950". An empty string is ignored; a nonsense colour does not throw, it simply draws nothing.
blinkbooleanWhen true, the dot blinks once a second (good for "something is broken").
textstringOne 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.

optsDefaultWhat 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.
timeout15Seconds, 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:

FieldWhat it is
statusThe HTTP status code, or 0 when there is none.
okA getter: 200 ≤ status < 300.
headersAn 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.
bodyThe 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.

optsDefaultWhat it does
headers—As in get: an object.
timeout300Seconds, 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

BindingWhen it runsCan 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

SituationWhat 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 interval5 seconds, no re-entry
http.get timeout1–300 seconds, 15 by default
http.stream timeout1–3600 seconds, 300 by default
response body cap4 MB (silently truncated beyond it)
log keptthe last 200 lines per icon
onStop time to finishabout 80 milliseconds
delay before a crashed host comes back5 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 onStart only: no ticks, and no simulated clicks. So a script whose logic is all in onTick shows 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

  1. Open Settings → AI Agent.
  2. The list on the left is the Agents found on this machine, each with its state (not installed / skill installed / needs update / not detected).
  3. Pick one and click Install. The skill is written into that Agent's own skills directory, touching only the one subdirectory <skills directory>\duiqidock\.
  4. Go back to that Agent and just talk to it, e.g. "add an internal dashboard to the dock".
AgentWhere 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 canMeaning
LookThe icon list and every field of one icon, the settings, and each icon's running state and recent log.
Change iconsAdd / edit / delete / reorder — through exactly the same path the UI uses, so the dock changes the moment it is done.
Manage processesStart / stop / restart / open the program behind an icon, or click an indicator.
Change settingsIcon size, theme, whether names are shown, asking where to save, the server address.
Install extensionsList the official extension market and put one of its entries on the dock.
UndoSee 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 duiqidock this 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.