dsh-action-cards
Verified@malipetek/dsh-action-cards Β· v1.0.0 Β· MIT Β· Web UI
Action cards: the agent proposes a concrete action (create a file, edit a value, run a command), you edit it and apply it in one click, and the Host executes it and reports back.
Install
dsh plugin add @malipetek/dsh-action-cards Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Tags
Creators
Readme
@malipetek/dsh-action-cards
The agent stops telling you to create a file by hand. It proposes the action, you fill in the values it left blank, and one click applies it.
Asking a model for a snippet you then have to paste, edit and save is the worst
part of working with an agent. You asked for a config file; it answered with a
code block containing YOUR_API_KEY; now you are in a text editor. Action cards
close that gap: the proposal arrives as a form in the Actions panel, with an
input for every value the agent could not know, a preview of the exact bytes, and
an Apply button. The Host performs the action under the same file sandbox and
confinement the built-in tools use, then tells the agent what actually happened.
What the agent can propose
| Step kind | What it does | Editing it |
|---|---|---|
write_file |
Creates or replaces a text file. Parent directories are created. | The path and the whole body. |
edit_file |
Replaces one exact piece of text in an existing file, so a value can change without rewriting the file. | The search text, the replacement, and whether every occurrence is replaced. |
run_command |
Runs one shell command in the session workspace, sandboxed, with a two-minute deadline and capped output. | The command line and the working directory. |
Any step may declare fields. Each field becomes an input in the Actions panel,
and {{name}} anywhere in that step's path, content, search, replacement, command
or working directory is replaced with what you type. A secret: true field
renders as a password box. This is the "replace a value in the snippet" case: the
agent writes the file it means, leaves {{KOOFR_TOKEN}} where your token goes,
and you supply it in the panel instead of in an editor.
What the agent has
Two tools. propose_action puts a proposal in front of you. return_action lets
the agent send one back β after you have answered You do it β with the reason
it cannot carry the job out, and optionally what it would need from you. It exists
so that "I can't, and here is why" is a thing the agent can say, rather than
something it has to smuggle into prose where nothing can act on it.
The agent is told to reach for it only when it genuinely cannot proceed, and not merely because the work is tedious: that is what the original proposal was for.
Three surfaces, one proposal
A review popup over the composer (
conversation.input.overlay, the anchor the slash menu and the command popup already use). It appears only while something is waiting on you: one line saying how many, what the first one is, and Review. Review opens the same editable form the panel uses, in place, so the answer is given where the notice was β and it offers the link out to the panel for everything the popup does not list. It can be hidden for the current count only: a notice you can never get back is one you stop using, and a new proposal brings it back.The Actions panel, a page in the right Sidebar (
ctx.sidebarRightTabs.register+ thesidebar.right.pane.tabseat), holds the editable form too and lists every proposal withWaiting/Applied/Failed/Handed back/Dismissedfilters β including the settled ones the popup stops showing.Each proposal there is a card, because it is an artifact rather than a turn, but the exchange beneath it is a thread: the agent's reason and your answer are bubbles, and the reply box is a composer whose send button is
You do it. Apply is a chip beside it, because doing the work here and asking the agent to are different intents.The panel re-reads the store every couple of seconds while it is open, so an action taken here β and an agent answering a hand-back with
return_actionβ appears without pressing Refresh. Acting also pins that card into view: the default view isWaiting, so otherwise a card would vanish the instant it was answered, taking its own outcome with it. Changing the view releases the pins.A one-line row in the transcript, keyed into
tool.call.toolviewunder thepropose_actionwire tool name. It says what was proposed and offers the way in β deliberately not a form. See below.
The popup sits over the composer rather than in the composer's dock row beside
the stats pills. The dock row is reachable, but a message about an action reads as
part of the composer, and in the dock row it read as a chip off to one side; the
overlay anchor is the one plugin seat that draws directly above the input without
this plugin guessing at an offset. Its slot order is negative, so the slash menu
and the command popup paint over it β while one of those is open, it is the thing
being read.
Three ways to answer
| Button | What it does |
|---|---|
| Apply | The Host performs the action, under the session's sandbox, and tells the agent the outcome. |
| You do it | Declines the work and hands it back: the agent is told to carry it out itself, and nothing is executed here. |
| Dismiss | Retires the proposal without telling anyone. |
And the agent has an answer of its own. If it genuinely cannot do the job β the
token is in an account it cannot read, the decision is yours β it calls
return_action with a reason, and the panel shows that reason with a field for
how it should proceed. Until you answer, You do it is refused: without
that the exchange is two parties restating their positions. Apply and Dismiss
stay available throughout, and a returned action counts as waiting, on the notice
and in the panel alike.
You do it exists because the agent sometimes proposes work it could have done itself, and proposing it is a way of handing over the responsibility. The message it sends names the steps but not the field values: those are masked in the panel so a token does not travel through the conversation, and forwarding them here would spend that for nothing.
Why the transcript keeps only a row
If you watch something appear while the agent is working and vanish the moment it
stops, that is the transcript's design, not a fault. dsh-client-ui-chat selects a
presentation policy from the persisted transcript mode:
detailed: { foldCompletedTurns: true, stepGrouping: "history", β¦ }
compact: { foldCompletedTurns: true, stepGrouping: "collapsed", β¦ }
standard: { foldCompletedTurns: true, stepGrouping: "collapsed", β¦ }
const grouped = policy.stepGrouping === "collapsed"
|| (policy.stepGrouping === "history" && turnLocation?.status !== "open");
Every mode folds a completed turn (foldCompletedTurns: true), and a tool call is
a step β so it folds with the turn, exactly like the bash and read rows
beside it. There is no transcript mode in which a settled turn keeps its steps
expanded.
An earlier version put the editable form here. It looked better in a screenshot and was worse in the hand: the form appeared while the agent worked, then folded away the instant it stopped β frequently mid-edit. A control that disappears while you are using it is worse than one that was never offered, so the form moved to the panel and the transcript kept a line that has nothing to lose by folding.
conversation.chat.turnTail is the one seat that would survive the fold β it is a
separate node kind, and the chat's own lastContent lookup skips turn-tail and
turn-process alike. It was not used because it receives { turn, seq, openFile }
and not the tool call, so matching a proposal to its turn needs a mapping nothing
in the API provides. Worth revisiting if the inline form is ever wanted back.
Why the Host does the work
The panel never touches the filesystem. Applying sends your edited values and the
steps to the Host through ctx.remote.commands.execute, which runs
/actions apply β¦ β no model turn, and nothing added to the transcript. On this
side the two halves share exactly one file, actions.json; the tool result carries the
proposal itself, so a missing or pruned store degrades the history but never
blocks an apply.
A third-party plugin cannot register a Remote namespace of its own, which is why
the Client drives a Cordis command rather than a bespoke RPC. This is the same
route dsh-projects-manager uses for /projects.
What the Host checks before you commit
When the proposal arrives, the Host looks at each target and records what it found, so the panel can warn you before you spend an apply finding out:
- a
write_filewhose target already exists says overwrites an existing file; - an
edit_filesays the text to replace occurs 3 times, or is not in the file, instead of failing later withFS_AMBIGUOUS_EDITorFS_EDIT_NOT_FOUND; - a check that cannot be made β because the path or the search text is itself a
{{placeholder}}β is deferred rather than reported as a misleading zero.
The same findings go back to the agent in the tool result, so it learns its own proposal would clobber a file.
Execution, honestly
- Writes go through
ctx.fswith the session's resolvedsandboxPolicy. Nevernode:fs. A write outside the workspace fails closed withFS_SANDBOX_DENIED, and the panel shows the reason. Commands go throughctx.shellwith the Seatbelt confinement thebashtool uses. - A
write_fileoverwrites without the read-before-write guard. That guard exists to stop an agent clobbering a file it never read; on this path the panel is the guard, because a human looked at the path and the content and pressed Apply. The pre-flight check is what told them it would overwrite. - Execution stops at the first failure. The step after a failed write usually depends on it, so a second, confusing failure is not useful. The panel and the agent both get the step that broke and why.
- Applying wakes the agent with the outcome. You asked for the Host to report
back, so it does: the agent learns what really happened rather than assuming its
proposal was followed. This costs one model turn per apply. Set
REPORT_TO_AGENT = falseinindex.jsto make applies silent. - Nothing is applied until you press the button. The tool result says so in as many words, because a model that believes its own proposal already happened is worse than no proposal at all.
Configuration
| Constant | File | Value |
|---|---|---|
STATE_DIR |
index.js |
$DSH_HOME/action-cards (DSH_HOME, else ~/.dsh) |
STORE_PATH |
index.js |
$STATE_DIR/actions.json |
DEFAULT_STORE_PATH |
client.js |
/Users/malipetek/.dsh/action-cards/actions.json |
MAX_STEPS |
both | 8 |
MAX_FIELDS |
both | 24 |
MAX_FIELD_CHARS |
both | 20000 |
MAX_ITEMS |
index.js |
200 records before decided ones are pruned |
DEFAULT_COMMAND_TIMEOUT_MS |
index.js |
120000 |
MAX_REPORT_CHARS |
index.js |
1200 |
REPORT_TO_AGENT |
index.js |
true |
TAB_KIND / TAB_ID |
client.js |
action-cards / @malipetek/dsh-action-cards/actions |
TOKEN |
both | /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g |
The Client half cannot read the Host environment, so DEFAULT_STORE_PATH is an
absolute literal. It is only a fallback: the resolved path travels to the row in
the tool result's metadata, and the row prefers it. If DSH_HOME is not
~/.dsh, the conversation row still works and the Sidebar panel reads the
literal β change DEFAULT_STORE_PATH to match. DSH_ACTION_CARDS_DIR overrides
STATE_DIR for smoke tests only; the running app never sets it.
Install
"/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh" \
plugin --profile desktop add ~/Documents/Documents/Projects/malipetek.github.io/packages/dsh-action-cards
Then enable the @malipetek/dsh-action-cards bundle in Settings β Plugins and
restart the app. The profile watches configuration by default but has
root: [], so a changed module needs a fresh JavaScript generation.
One canonical copy
This directory is the one the app installs and the one that gets published, so it is the only copy that matters β edit it here.
A second copy exists inside the agent workspace at
~/Documents/deepseek-harness/default-workspace/dsh-action-cards, kept
byte-identical by dev-sync.sh:
./dev-sync.sh # mirror this copy into the workspace
./dev-sync.sh --check # report drift, exit non-zero, change nothing
That mirror is not decoration. The desktop profile was once repointed from the
workspace copy to this one, and every later fix landed in the workspace copy
instead: the app kept loading a snapshot from hours earlier, and the symptom β
a change that is definitely saved and definitely not taking effect β is
indistinguishable from caching. --check is how to rule that out, and
dev-preflight.mjs is what to run before trusting any change.
It is already installed in the desktop profile as a link: dependency, with the
bundle appended to dsh.profile.bundles β so only a restart is outstanding. A
dsh --profile <name> --dump-config against a copy of the profile confirms the
composition, because the CLI refuses to touch the desktop profile while the
Electron app is running.
Try it
Ask for something with a value only you have:
Create
config/uploader.jsonwith my Koofr email and an app token I'll paste in, and then dry-run the uploader.
The agent should call propose_action. A one-line row appears in the conversation,
and the review popup appears directly above the chat input reading 1 action is
waiting for you β press Review for the form: two inputs, one of them masked,
and Apply / You do it / Dismiss.
The model is told to reach for this tool whenever a step has to happen in your environment, so it does not need asking twice; if it answers with a code block anyway, say propose that as an action. If it proposes something it could have done itself, You do it sends the job back.
The command surface, if you want it from the keyboard:
/actions list every proposal and its status
/actions apply <id> {json} apply one proposal with the user's values
/actions delegate <id> {json} hand the work back, with an optional note saying how
/actions dismiss <id> mark one proposal dismissed
/actions prune drop every decided proposal
Development
None of it needs the app to test:
node dev-preflight.mjs # packaging + tool schemas: the rules the DSH loaders and registry enforce
node dev-smoke.mjs # host: proposal, substitution, write, edit, command, failures
node dev-client-smoke.mjs # client: registrations, the row, the panel, the notice
./dev-react/setup.sh && node dev-react/render.mjs # client, under real React in a real DOM
./dev-integration/setup.sh && ./dev-integration/run.sh # host, against the real sandbox and shell
dev-preflight.mjs reproduces the loader's own rules, so a packaging mistake is
caught here rather than in the app's log after a restart: the Client bundle
registers a factory whose id must equal the package name, exports["./client"]
must resolve, the bundle patch must insert the package, the Host half must import
with nothing but node builtins, and both tool schemas must stay inside the
keyword subset the registry enforces.
dev-smoke.mjs runs the Host half against a scratch workspace with an fs and
shell shim shaped like the real services, so the substitution and every failure
path are exercised. dev-client-smoke.mjs runs the browser bundle against a
miniature React and walks the element tree β fast, dependency-free, and enough to
catch a component that throws.
dev-react/ goes further: it mounts the real bundle with
react-dom/client into jsdom and drives it as a person would β typing into the
masked field through React's own event system, clicking Apply, and asserting that
the edited value, the steps, the action id and the session scope all arrive. It
also proves the preparing β start phase change remounts instead of breaking
the rules of hooks, which the miniature runtime cannot detect.
dev-integration/ closes the last gap, driving the
plugin against the real ctx.fs, ctx.shell and ctx.sandboxPolicy.
None of these are shipped: package.json files omits them.
The Host half logs one line at startup, in the same style as the other local plugins, so a mount can be confirmed without the UI:
[action-cards] ready β propose_action, return_action and /actions, store /Users/you/.dsh/action-cards/actions.json
It also writes $STATE_DIR/doctor.json, in the same spirit as
dsh-projects-manager's doctor, recording what it reached and whether its two
registrations succeeded. Every service this plugin uses is read lazily through
ctx.get, so a missing one degrades quietly; this file is what makes the
difference between "loaded" and "loaded but cannot write a file" visible from
disk:
{
"plugin": "action-cards",
"version": "1.1.0",
"probe": "settled",
"tool": "ready",
"toolError": null,
"returnTool": "ready",
"returnToolError": null,
"command": "ready",
"services": { "fs": "ready", "sandboxPolicy": "ready", "shell": "ready", "shellEnv": "ready", "sessions": "ready" },
"store": "/Users/you/.dsh/action-cards/actions.json"
}
The Client half announces itself once at mount with /actions hello, which writes
$STATE_DIR/client.json. Together those two files are how a change is confirmed
to be live rather than merely saved β a browser fetches the Client bundle, so
a stale one behaves exactly like a working one until it can be asked what version
it is:
{ "at": 1791645139436, "client": "1.1.0", "host": "1.1.0", "sessionId": "β¦" }
client.json missing while doctor.json is current means the Client bundle is
stale; both missing means the app never loaded the plugin. Bump PLUGIN_VERSION
in index.js and CLIENT_VERSION in client.js together.
Integration test β the real services
The suites above run in plain Node, against shims shaped like the real services.
dev-integration/ closes that gap: it mounts the
plugin in a throwaway profile and drives its own tool and command against the real
ctx.fs, ctx.shell and ctx.sandboxPolicy β 29 checks, including the sandbox
refusing a write outside the workspace.
./dev-integration/setup.sh && ./dev-integration/run.sh
A trap worth knowing about
await import('@deepseek-ai/dsh-llm') does not resolve from a
profile-installed plugin. The Harness packages live inside the application's
asar; a plugin's realpath is its workspace directory, and the profile's own
node_modules holds only @local/*. The import fails with
ERR_MODULE_NOT_FOUND, and because first-party code wraps such deliveries in a
try, the failure is silent.
Both other local plugins take that route β dsh-projects-manager's
/projects remind cannot deliver its briefing for this reason. This plugin
attempts the import first and falls back to building the message itself, which is
createUserMessage verbatim: { ...input, role: 'user', id }, deep-frozen, with a
source.kind of action-cards. That is the same shape @deepseek-ai/dsh-schedule
uses to deliver a reminder, and the Client treats an unrecognised source.kind the
same way it treats schedule β no special case, no breakage.
The tool schema subset is smaller than JSON Schema
The Harness enforces a subset: type, oneOf, properties, required,
additionalProperties, items, enum, const, plus the annotations
description, title, default, examples. Nothing else.
ctx.tools.register() asserts this on the output schema and rejects the tool
outright. It does not assert on parameters β but an out-of-subset keyword
there is silently ignored by argument validation and by the generated PTC types,
so it reaches the provider unenforced and merely misleads the model.
This plugin's first draft used minItems/maxItems on steps, which is exactly
that trap: the model would read "up to 9 steps are fine" while normalizeProposal
rejected them. The bounds now live in the step's description, where the model
actually reads them, and normalizeProposal is the only enforcement β with a
clearer message than a schema violation would give. No shipped tool uses an
out-of-subset keyword, so this is worth checking before adding one.
dev-preflight.mjs now fails the build if one reappears.
Limitations
- One proposal is one tool call. An apply is all-or-following-nothing-at-the-first- failure; there is no per-step retry, and a failed apply leaves a new proposal rather than resuming the old one.
- The store is a single JSON file rewritten whole. It is written atomically, and a torn read is tolerated, but concurrent applies from two windows serialise through one queue per process.
- The Sidebar panel reads the store, not the transcript, so a proposal made before the store existed shows on its row and not in the list.
- A
run_commandstep cannot be made interactive: it runs to completion, capped at 64 KB per stream, and its exit code is the outcome. Use the terminal tool for anything that needs a TTY. - Applying an
edit_filewhose file changed since the proposal fails withFS_STALE_VERSIONrather than forcing the edit. Re-propose it. - Copy is plain English rather than routed through the Client locale service, so it
does not switch language. This matches
dsh-projects-panel. - The outcome message is hand-built, because the message factory cannot be imported
from a plugin (above). It is reproduced field for field, and the inbox projection
admits it on the same terms as a factory-built one, but a future change to
createUserMessagewould not be caught by a type error here. - The panel only appears in the right Sidebar's guide (the strip's add control) or via the row's Open button. There is no always-visible seat for a contributed tab.