Per-agent plumb session identity for DeepSeek Harness.
A cordis plugin that gives every DSH conversation, workspace, and in-process subagent its own stable plumb session identity — enforced at the transport layer, not by instruction. Developed and tested against @deepseek-ai/dsh@0.1.1-rc.2 and plumb 0.17.x; its field locations were re-verified by inspection on 2026-09-16 against @deepseek-ai/dsh@0.1.5-rc.1 (MCP SDK 1.30.0) — see the compatibility note at the end for exactly what that does and does not claim.
DSH multiplexes every conversation over one plumb serve connection per process, and DSH's MCP client (@deepseek-ai/dsh-mcp-client) sends no identity: no per-conversation session id, no per-call _meta. plumb's isolation layer — per-agent workspace shards, read tracking, mail addressing — arms on a declared identity, so unidentified conversations can force-re-pin each other's workspaces. The usual mitigation is an instruction in ~/.dsh/AGENTS.md asking each conversation to declare a session_id; that covers main conversations, but DSH subagents run in-process with their own system prompt and no parent context, so they are structurally anonymous.
plumb accepts an agent identity on two channels, both used here:
session_start { session_id, workspace, purpose }— declares the agent, pins its own workspace shard, and records the linkage (stable ids inherit plumb session names across resumes).- a per-call
tools/callparams_meta["dev.plumbkit/logical-agent"]— always honoured by plumb; the fallback that keeps every call attributed even when no declaration has happened yet.
- Correlate. A listener on DSH's
tools/executewaterfall sees every tool call withexec.agent— the caller, conversation or subagent, each with its own session UUID and workspace. Formcp__<serverName>__*calls it mints a stable iddsh-<workspace-slug>-<session-short>and runs the rest of the pipeline inside anAsyncLocalStoragescope. - Stamp. A narrow patch on the MCP SDK
Client.prototype.request(the exact class instancedsh-mcp-clientuses, imported by absolute path from DSH's shared profile tree at module load) adds_meta["dev.plumbkit/logical-agent"]totools/callrequests made inside such a scope. The scope is armed only around plumb tool executions, so a stamped request is by construction a request to the plumb server. The same patch captures the plumbClientinstance the moment its connection names itself — the stdio transport's server params sayplumb serve— during the SDK's initial tools sync, long before any agent exists. - Declare. With the captured instance, the plugin issues a proactive
session_start { session_id, workspace, purpose }for each agent's first plumb call — outside the identity scope, so the id travels in the arguments where plumb's linkage records it and the shard pins its own workspace. Per-Clientbookkeeping becausedsh-mcp-clientbuilds a fresh client on every reconnect generation, and a new connection must re-declare.
Everything fails open: SDK missing, patch refused, declaration rejected — the call proceeds and the AGENTS.md instruction surface remains the fallback. A tool call must never break because its observer could not describe itself. Failing open is not the same as failing silently, though: a failure that would leave calls unstamped is reported once in the DSH log, because the alternative is a plugin that looks installed and healthy while plumb refuses every write it cannot attribute.
Requires a running plumb daemon (plumb serve reachable as an MCP server named plumb — see plumb setup dsh).
dsh plugin --profile web add dsh-plumb-identity
dsh plugin --profile headless add dsh-plumb-identityThe package ships a dsh.bundle patch, so installing the bundle also mounts the plugin with default config. Restart dsh (running processes do not re-read patch layers).
Pick one mount mechanism. Installing the bundle and keeping a manual
insertrow with the same id crashes DSH at boot —duplicate loader entry id: dsh-plumb-identity. Patch layers merge config overrides by row id, but two inserts never merge. If you switch between the two, remove the other first (dsh plugin --profile <name> remove dsh-plumb-identity, or delete the row).
Only if the bundle is not installed — append to ~/.dsh/cordis.patch.yml:
- insert:
- id: dsh-plumb-identity
name: dsh-plumb-identity
config:
serverName: plumbRemove the row, or disable it with an override row at the TOP LEVEL of any patch layer loaded after the one that mounts it (never inside an insert list — that would insert a nameless row):
- id: dsh-plumb-identity
disabled: truePoint the row at a checkout instead of the package name — and remove or disable the bundle mount first, per the warning above:
- insert:
- id: dsh-plumb-identity
name: 'file:///absolute/path/to/dsh-plumb-identity/dsh-plumb-identity.mjs'
config: { serverName: plumb, logEvents: true }| field | default | meaning |
|---|---|---|
serverName |
plumb |
which MCP server's tools to guard (mcp__<serverName>__*) |
idPrefix |
dsh |
first segment of minted ids |
purpose |
dsh |
session_start purpose tag for conversations |
subagentPurpose |
dsh-subagent |
purpose tag when the session header shows parentSession/delegationDepth |
detail |
brief |
orientation packet size for the proactive session_start |
excludeEnv |
["PAUTA_RUN_ID"] |
skip entirely when any of these env vars is set |
connectMarker |
plumb |
substring matched against the stdio command to recognise the plumb connection |
logEvents |
false |
per-call stamp/capture debug lines |
sdkPath |
DSH profile tree | absolute path to the MCP SDK's client/index.js (non-standard installs) |
excludeEnv matters: pauta's dsh driver appends its own plumb linkage sentence (pauta-dsh-<card>-<run>), and a second identity for the same run would be noise.
Know what the exclusion costs. An excluded run mounts nothing, so its plumb calls carry no identity at all. On a shared connection those anonymous calls resolve against the CONNECTION's pin, and a pin such a run sets is what seeds every other agent's shard — so a pauta run can leave a DSH conversation seeded on another workspace. Two things bound the damage: plumb refuses anonymous state-changing calls once two identities have been seen on the connection, and (since 2026-09-16 in plumb main) an agent whose declaration is refused is gated — its path-bearing calls are refused by name with the force: true remedy instead of quietly resolving into the seeded root. If you would rather the pauta run be attributed by this plugin, remove PAUTA_RUN_ID here and have the driver pass its run id through the same channels — but verify the driver's contract first; the blanket exclusion is deliberate, not an oversight.
# identities and their workspaces, per agent
sqlite3 "file:$HOME/Library/Application Support/plumb/session_state.db?mode=ro" \
"SELECT logical_agent_id, workspace, source FROM pinned_workspace WHERE logical_agent_id != '';"
# declared sessions (external_id = minted id, purpose = dsh / dsh-subagent)
ls -t "$HOME/Library/Application Support/plumb/sessions/" | headWith two conversations in two workspaces plus a subagent you should see ≥3 distinct logical_agent_ids, each pinned to its own workspace, and no pin-contest notices in the daemon log. plumb sessions and the workspace_sessions tool show the same picture live.
npm testHermetic — no DSH, no plumb, no network (the MCP SDK is a stub fixture). spike/probe.mjs and spike/observe.mjs are the field diagnostics: mount either through a --patch overlay into a dsh --profile headless run to re-map DSH internals after an upgrade.
npm run test:e2eVerifies the full chain — real DSH agent loop, this plugin mounted from the working tree, real plumb — with only the MODEL scripted (test/fake-model.mjs, a local OpenAI-compatible server), so it spends zero LLM credits and touches no network beyond localhost. Each scenario runs in a throwaway DSH_HOME whose only configured provider is the local fake (a misrouted model request fails on "unknown provider" rather than billing a real key) against an isolated plumb serve whose HOME/XDG roots live under the scenario's temp dir — neither your DSH profile nor your plumb state is touched.
Three scenarios, each asserting on plumb's own session records:
tool-call— one plumb tool call; a session withpurpose: dshandexternal_id: dsh-*must be declared.text-only— no plumb tool call; nothing may be declared (the control).subagent— the agent delegates a subagent that makes the plumb call; apurpose: dsh-subagentidentity must be declared.
Requirements: a dsh install (resolved from $DSH_BIN, else the $DSH_HOME / ~/.dsh profile tree, exactly what dsh itself resolves) and a plumb binary ($PLUMB_BIN, else ../plumb/plumb beside this checkout, else plumb on PATH). Run one scenario with node test/e2e-mock-provider.mjs --mode subagent; keep the temp dirs for debugging with --keep or E2E_KEEP=1.
The plugin no longer only stamps identity onto the connection DSH shares between
agents: for each agent it opens its own plumb serve, declares that agent on
it, and dispatches the agent's mcp__plumb__* calls over it. The connection
carries the identity, so nothing needs stamping and two agents cannot be
confused for each other.
- The shared connection stays for tool discovery and resource reads; only execution moves.
- It falls back to the shared path only BEFORE a call is dispatched (no connection could be opened, or the per-agent server does not advertise that name). A call that has been sent is never re-sent elsewhere — plumb's writes are not idempotent, so a "retry" could apply one twice.
perAgentConnection: falserestores the stamping-only behaviour.plumbCommand, else$PLUMB_BIN, elseplumbon PATH selects the binary.idleMs(default 15 s) closes a connection that has gone unused. It is also the only teardown DSH performs: its headless path never disposes plugins, so without an idle close the host process outlives the run that finished.PLUMB_IDENTITY_DEBUG=1traces the connection lifecycle to stderr.
- A model that follows
~/.dsh/AGENTS.mdand callssession_startwith its own id changes nothing: the per-call_metastamp wins plumb's identity resolution, so the conversation stays under its minted id. Keep the AGENTS.md rules — they are the fallback when the plugin is absent and the path through which the model receives plumb's orientation packet. - A plumb connection keeps ONE session record;
external_id/purposeinplumb sessionsshow the most recent declarer on that connection. Per-agent isolation is plumb's shard state (pinned_workspace/read_trackingkeyed bylogical_agent_id), not the session record. - Single-identity connections pin at connection level by design ("one declared id arms nothing"); sharding and per-agent pins engage from the second distinct identity, which is exactly the multi-workspace web case.
- A refused declaration is a failure, not a success. plumb answers a refused
session_startwith an ordinary tool RESULT carryingisError: true— not a transport error — so atry/catchsees success. This plugin inspects the result, and lets plumb's own scope decide the recovery:details.scope = "agent"meansforce: truemoves only this agent's shard, so it retries once with force;details.scope = "connection"means force would move the pin every agent on the connection resolves against, so it is reported and never forced automatically. Concurrent first calls from one agent share a single in-flight declaration. - Compatibility is pinned to
@deepseek-ai/dsh@0.1.1-rc.2field locations (agent.id,agent.session.header.{cwd,parentSession,delegationDepth}, thetools/executewaterfall, SDKclient/index.js). On 2026-09-16 those were re-verified by inspection against@deepseek-ai/dsh@0.1.5-rc.1with MCP SDK 1.30.0: live DSH conversations still appear in plumb'spinned_workspacerows under this plugin's minted ids, and the SDK'sResultSchemais az.looseObjectwith_metadeclared, so the failure envelope this plugin reads (_meta["dev.plumbkit/error"]) survives parsing. The spike probes were not re-run for 0.1.5-rc.1 and the tested target has not been bumped — treat 0.1.1-rc.2 as tested and 0.1.5-rc.1 as observed-working. If a DSH upgrade moves one of these, the plugin fails open and reports it in the DSH log; re-run the spike probes to re-map, and please open an issue. - 2026-10-01 — the stamp patch can be installed and inert, which is invisible unless it is reported.
@deepseek-ai/dsh@0.2.0-rc.2'sdsh-mcp-clientdepends on@modelcontextprotocol/client@2.0.0, while this plugin patches@modelcontextprotocol/sdk(1.30.0) from${DSH_HOME}/profiles/node_modules/…— a path that resolves through a symlink into the npx cache generation of an earlier launch. The import succeeded, the patch installed on a class nothing instantiates, and no warning fired, because the only check was whether the import threw. The symptom was plumb refusing every state-changing call as unattributed, with nothing on the client side to explain it. The plugin now counts routed plumb calls against applied stamps and warns once per apply when a call completes with nothing stamped, naming the path it patched. Two structural follow-ups remain: resolve the MCP package from the running DSH's own module tree rather than a fixed profile path (npxmints a new generation directory per launch), and port the patch to@modelcontextprotocol/client@2.0.0.