-
Notifications
You must be signed in to change notification settings - Fork 2
LAN Manager
plugins/dsh-lan-manager is a DeepSeek Harness bundle that puts a small HTTP API
in front of a running dsh instance — and finds the other instances running it.
One machine on your network can then list what is open anywhere in the group, send
prompts, and tidy up workspaces and sessions, without a window and a mouse.
It exists because a harness instance is otherwise only reachable through its
browser UI on 127.0.0.1. When you run several — one per Mac, or a rack of Linux
boxes in datacenters — there is no supported way to ask "what is running over
there, and can I drive it?".
Caution
This plugin deliberately accepts requests from other machines. Read Security before enabling it, and never port-forward it to the public internet.
| Capability | Endpoint |
|---|---|
| List active workspaces — the ones the web page shows | GET /dsh-lan/workspaces |
| List every visible session | GET /dsh-lan/sessions |
| List one workspace's sessions | GET /dsh-lan/workspaces/:id/sessions |
| Prompt one session | POST /dsh-lan/prompt |
| Prompt every active session | POST /dsh-lan/prompt-all |
| Read a session's messages back — the answers, not just the questions | GET /dsh-lan/sessions/:id/messages |
| Archive a session | POST /dsh-lan/sessions/:id/archive |
| Register an existing folder as a workspace | POST /dsh-lan/workspaces |
| Delete a workspace | POST /dsh-lan/workspaces/:id/delete |
| Who else is in the group, and what each one holds |
GET /dsh-lan/peers · GET /dsh-lan/peers/:id
|
| The whole group in one answer: this Mac and every member | GET /dsh-lan/inventory |
| Share address lists between members (mesh) | carried in the peers array of GET /dsh-lan/inventory — there is no push endpoint, by design |
| Liveness, and where the request came from | GET /dsh-lan/health |
Active means what the page shows. A session is visible when it is not in the harness's archive set; a workspace is active when at least one visible session lives in it. Archiving hides a row and keeps its history — it never deletes.
dsh plugin --profile web add ./plugins/dsh-lan-managerThe bundle patch mounts the row, so the next dsh web picks it up. No --patch
flag is needed.
It cannot, on the pinned harness, and the plugin has no way around it. The API
is registered on the harness's own web server, so it is reachable exactly where
that server is — and @deepseek-ai/dsh-host-webserver accepts only two bind
addresses:
$.host expected "127.0.0.1" | "0.0.0.0" but got "192.168.1.50" (at host)
127.0.0.1 is the default, and answers this machine alone. 0.0.0.0 — the value
that would put the API on the network — is refused by the harness before it binds:
error: --host 0.0.0.0 is intentionally not supported yet for safety:
it would expose remote code execution to the network; use 127.0.0.1 instead
Naming a specific interface instead does not help: the webserver plugin's schema
rejects it, and the profile fails to load. So today only the machine running the
harness can call this API, even though every request is fenced and token-gated and
the fleet-wide prompt is already built for the day that changes. The unblock is
upstream's — a third ask alongside the two in docs/dsh-upstream-asks.md.
For now, a caller on the same machine:
curl -H "x-dsh-token: $DSH_LAN_KEY" http://127.0.0.1:3080/dsh-lan/healthEvery instance carries one group key — a string, default tinytitan-lan, and
the first thing to change on a network you do not solely own. Instances with the
same key are one fleet, and the key is also the door key each request presents.
Discovery runs by itself, every 60 seconds by default:
| Source | Finds | Notes |
|---|---|---|
| Tailscale | online tailnet peers with an IPv4 address | Any continent. Mobile platforms (iOS, Android) and IPv6-only peers are skipped, as are peers that are offline |
Bonjour (_dsh-lan._tcp) |
a third-party advertiser, and nothing else today | Browse-only, and idle here. Nothing registers the service, and registering it would advertise this host's LAN address where nothing is listening, because the API answers on loopback alone. It does not cross a tailnet either — multicast stays on the link |
peers: |
whatever you seed | The escape hatch for a host on a non-default port or behind an ACL |
Local /24 sweep |
hosts that advertise nothing | Off by default: the only source that touches machines which never opted in |
Each member keeps the others' active workspaces and sessions, and members trade address lists with each other, so one found machine is enough for the rest to learn the whole group. Two rules keep that safe:
- Nothing is dialled before it is validated. Every candidate — including one another member told us about — must resolve to an address inside the same LAN/Tailscale allowlist the request fence uses, and must answer with our group key, or it is dropped.
- A member's list is a hint, never authority. Gossiped addresses are candidates for the next cycle, validated then like any other. A poisoned list cannot become a connection.
Two things that will make a machine invisible, and the fix for each: a tailnet ACL
that blocks the peer port, and a harness listening on a port other than 3080 —
the Tailscale listing carries no port, so that host needs a peers: entry.
Discovery never blocks the window: every expensive part is asynchronous I/O, so
nothing occupies the event loop. A name that has gone away is the one real cost
— a stale Bonjour entry takes the full mDNS timeout — so answers and failures
are cached for resolveTtlMs (default five minutes), resolveConcurrency
(default 4) bounds how many slow lookups are in flight, and the discovery timer
is jittered by ±10 % so a fleet installed together does not run every cycle in
lockstep for ever.
A single prompt box is a convenience. Every prompt box at once, from one command, is an operational tool — and the thing it buys is time. These are the jobs it is for, cheapest first.
prompt-all reaches every active session in the group within seconds, so a
runaway fleet costs seconds instead of an afternoon:
ttlanmanager prompt-all --text "STOP. Do not start new work. Finish nothing. \
Write a one-line status to STATUS.md in your workspace and wait for instructions."The same call is the response to a billing spike, a shared API quota about to be exhausted, or a site losing power.
Ask every agent what it is doing, before you touch anything, and then read the answers back:
curl -H "x-dsh-token: $DSH_LAN_KEY" -H 'content-type: application/json' \
-d '{"prompt":"Report your current task, the files you have open, and anything uncommitted, in three lines."}' \
http://127.0.0.1:3080/dsh-lan/prompt-all
curl -H "x-dsh-token: $DSH_LAN_KEY" \
"http://127.0.0.1:3080/dsh-lan/sessions/<session-id>/messages?limit=4"The answers arrive per session, so one machine that cannot be reached does not stop
the audit. Reading goes through the live agent's session — one the UI has open
or that is running — because that is where the harness derives history from; a
session with no live agent answers 404 and says so, rather than reporting an
empty conversation.
A vulnerability, a poisoned dependency, a bad instruction in a shared prompt — one
call tells every instance at once, and session archive freezes the ones that
touched it:
ttlanmanager prompt-all --text "SECURITY: stop using package X, remove it from \
your working set, and report anything that imported it."
ttlanmanager session archive --session s-abc123When a key leaks, the leak is not the only problem — it is every agent that still holds it in context. Tell the whole group to drop it, in one move:
ttlanmanager prompt-all --text "Rotate: the token in your context is revoked. \
Stop using it, and list every file where you wrote it."Before cutting a tag, make sure no agent is mid-write:
ttlanmanager prompt-all --text "Release freeze: finish the current step only, \
checkpoint, and do not start anything new."
ttlanmanager list # confirm every workspace is quietA new Mac joins and you point its work at a folder from wherever you are:
ttlanmanager workspace create --on new-mac --path /Users/me/Project --title ProjectBefore wiping a box, take its work with you — from another continent, over the tailnet:
ttlanmanager list # note its workspaces and sessions
ttlanmanager session archive --session <id>
ttlanmanager workspace delete --workspace <id>/peers is a heartbeat for free: a machine that stops answering drops out of the
group, and that is itself the alert — the Frankfurt box stopped reporting, the
laptop's tailnet dropped, one datacenter lost its link.
ttlanmanager list --json | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["peers"]), "members")'Target sessions individually to divide a job by machine, or hand a blocked task to a different host:
ttlanmanager prompt --session s-client --text "Take the client side only."
ttlanmanager prompt --session s-server --text "Take the server side only."The same prompt on every machine is a benchmark across hardware — the fleet is
your test matrix, and prompt-all is the harness driver.
A new instruction file, a new skill, a changed house rule: broadcast it instead of editing N machines by hand.
/inventory reports each session's turn count, so an agent that has been grinding
for hours is visible before the invoice arrives; archive it and tell the rest to
bound their work.
-
A session with no live agent has no readable history.
GET /dsh-lan/sessions/:id/messagesprojects the live agent's own derivation, so a session that has gone idle cannot be read; it answers404and says so rather than reporting an empty conversation. A cold read needs the session store. -
Starting a session needs a profile that composes the harness session
controller. Where it does — the web profile does —
POST /dsh-lan/sessionsworks, andPOST /dsh-lan/workspacestakes"startSession": true. Where it does not (a headless or SDK-only runtime) the endpoint answers501and says why rather than pretending.
sources/TinyTitanFleet builds ttlanmanager, the TinyTitan DSH LAN
Manager. It reads
the group from any one member and then talks to the member that owns the thing
being acted on — a plugin never relays a prompt for another instance.
swift build -c release --product ttlanmanager
ttlanmanager top # the live dashboard
ttlanmanager list
ttlanmanager prompt --session ID --text "..."
ttlanmanager prompt-all --text "..." [--limit N]
ttlanmanager workspace create --on NAME --path DIR [--title TITLE]
ttlanmanager session archive --session ID
ttlanmanager workspace delete --workspace ID [--keep-sessions]--peer is the member the group is read from (default 127.0.0.1:3080);
--on names the member an action goes to; --json prints the raw answer. The key
resolves from --key, then DSH_LAN_KEY, then DSH_LAN_TOKEN, then the default.
ttlanmanager top draws the group live and refreshes it on a timer. Each
member is one row, and opening it shows its workspaces and the sessions inside
them:
ttlanmanager · tinytitan-lan · 5 members · 3 ws · 4 sess · refreshed 0s
NAME TYPE ADDRESS DSH WS SESS SEEN IPV6
▾ mac-a this Mac 127.0.0.1:3080 0.1.6-alpha.2 1 1 — fd7a:115c:a1e0::11
/Users/you/Proje… workspace w-ab 1
Release v5.7 session s-ab
▾ node-b Tailscale 100.64.0.11:3080 unknown 1 2 4s fd7a:115c:a1e0::12
/Users/you/Downl… workspace w-n3 2
story benchmark … session s-n3a
▾ node-c Tailscale 100.64.0.12:3080 unknown 0 0 1m
Only this machine's harness version is known. A peer reports the plugin's
version, not its harness's, so a remote row prints unknown; a manager that wants
the harness version of every member needs the peers to publish it. What the column
does establish is that every member is running the plugin at all — and therefore
the one harness release it supports, since it refuses to start on any other.
| Key | Does |
|---|---|
↑ ↓ / k j
|
move through members, workspaces and sessions |
→ / Enter
|
open the selected member; ← closes it |
p |
prompt the selected session — or, on a workspace row, every session in it |
P |
prompt every session on the selected member |
A |
prompt every session in the whole group |
c |
register a folder as a workspace on the selected member |
a / d
|
archive the selected session / delete the selected workspace (both confirm first) |
r / ? / q
|
refresh now / help / quit |
It scans in the background. A scanner on its own task polls the fleet every
30 seconds — half the plugin's discovery period, so the manager reads no
faster than members are found while its polling adds at most half a cycle of
latency. Each scan asks the seed, then asks every member directly rather
than trusting one machine's cached view of another: a member's own answer is the
truth, it names members the seed has not learned yet, and it is what makes a new
machine appear within one interval. Members are matched by machine name, not by
id, because the same Mac is mac-a:3080 to itself and
100.64.0.11:3080 to a peer — keying on the id would list it twice.
Nothing in the window waits on the network: an unreachable member costs a timeout in the background, and prompts and mutations run on their own task so the window keeps drawing while a fleet-wide prompt is delivered.
It resizes. Columns give ground in a fixed order as the window narrows — the IPv6 address first, then the age, then the harness version, then the type — and the two that carry the answer, the member and where it answers, are the last to go. A window too small to draw a table says so instead of drawing rubbish, and the selection keeps its place across a refresh.
Run it outside a terminal (--once, with --width/--height) and it prints a
single frame instead — which is also how you render an inventory captured
earlier, with --from FILE or --from - for stdin, and no fleet running at all.
If the network has neither Tailscale nor Bonjour, drive the addresses you know:
for host in 192.168.1.10 192.168.1.11; do
printf '%s: ' "$host"
curl -fsS --max-time 5 -H "x-dsh-token: $DSH_LAN_KEY" "http://$host:3080/dsh-lan/workspaces" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d["workspaces"]), "workspaces", sum(w["sessionCount"] for w in d["workspaces"]), "sessions")' \
|| echo unreachable
doneThree checks run in this order, and the first one happens before the request body is read:
-
Source address. Allowed: loopback (
127.0.0.0/8,::1/128), the private ranges10.0.0.0/8,172.16.0.0/12,192.168.0.0/16andfc00::/7, link-local (169.254.0.0/16,fe80::/10), and100.64.0.0/10— the Tailscale range, so peers on a tailnet work without configuration, wherever they are in the world. Everything else is refused, including any address that cannot be parsed. The peer address comes from the socket;X-Forwarded-Foris deliberately not trusted, because a forwarded header is attacker-controlled and trusting it would let any caller claim to be loopback. -
The group key, compared in constant time. It ships with a public default (
tinytitan-lan) so a fresh install joins the group with no setup — which means by default it groups rather than protects. Change it on every machine when the network is not entirely yours:export DSH_LAN_KEY="$(openssl rand -hex 24)"
Clients send it in
x-dsh-token;DSH_LAN_TOKENis accepted as the same variable under its older name. -
Origin, on requests that change something. A page from an unrelated site, loaded in an allowlisted browser, must not be usable to drive the API.
The harness web server itself carries no TLS and no authentication of its own. This plugin fences its own routes; it does not secure the UI beside them.
| Key | Environment | Default |
|---|---|---|
basePath |
DSH_LAN_BASE_PATH |
/dsh-lan |
groupKey / token
|
DSH_LAN_KEY, DSH_LAN_TOKEN
|
tinytitan-lan — change it |
peers |
DSH_LAN_PEERS |
[] — seed addresses, host or host:port
|
discoveryIntervalSeconds |
DSH_LAN_DISCOVERY_SECONDS |
60 |
discoverTailscale |
— | true |
discoverBonjour |
— | true |
discoverSubnet |
— | false |
peerPort |
— | 3080 |
probeTimeoutMs |
DSH_LAN_PROBE_TIMEOUT |
3000 |
allowAddresses |
DSH_LAN_ALLOW |
[] — extra hosts or CIDRs to admit |
includeEmptyWorkspaces |
— | false |
maxBodyBytes |
— | 262144 |
These are the operator-facing keys. A few more exist and are not repeated here,
because the security section above is their description: ipv4Networks,
ipv6Networks, originNetworks and enforceOrigin (the three checks),
trustedOrigins and allowPrivateOrigins (Origin exceptions for a known client),
logToHost, discoveryConcurrency, resolveConcurrency, resolveTtlMs, and the
DSH_LAN_RESOLVE_CONCURRENCY environment fallback. Their defaults live in
plugins/dsh-lan-manager/src/config.js — change them there rather than guessing
from this table.
Active workspaces are derived from the session projection, not from the
harness workspace registry. The registry holds only the directories a user
explicitly added, while the page groups sessions by the directory each one ran
in; reading the registry would answer a different question than the one being
asked. A workspace that exists only because sessions ran there is reported with
"registered": false and a null id, and can still be addressed by path.
Prompting requires a live agent for the target session — one the UI has open
or is running. A session with no live agent returns 404 rather than silently
queuing work, and prompt-all reports that per session instead of failing the
whole request.
Start
Use TinyTitan
DeepSeek Harness
Reference
Project