Remove content-shape and message-type attachment filtering, recover forwarded unknown attachments, and preserve original media across all agent backends.
Before, /new and /clear were identical: both just dropped the local
conversationId->sessionId mapping. The agent-side session was never
disposed (opencode sessions leaked), and the two commands had no real
difference beyond their ack text.
Align them to each channel's real capability:
- /new (resetSession): drop the local mapping only; the old agent session
is left intact and stays resumable where the agent supports it.
- /clear (new sessionClearer): actively dispose the current session via the
agent's real delete primitive. opencode implements it with
DELETE /session/:id (idempotent on 404). Channels whose agent exposes no
delete in the mode DWS drives it (Codex app-server, Qoder stream, Claude
exec) fall back to resetSession, so /clear behaves like /new there.
Tests pin both directions: /clear issues exactly one DELETE and drops the
mapping; /new (resetSession) issues zero DELETEs so the old session stays
resumable. Doc updated to describe the per-channel behavior.
go build ./... ok; go vet ok; go test ./internal/helpers ok.
opencode persists its own sessions but, unlike the Claude family, will not let
the caller mint the session id up front: `opencode run` creates a session on the
first turn and only reports its id in the `--format json` event stream, then
continues it with `--session <id>`. So the connector now CAPTURES the id from
the output (like codex's thread id) instead of minting a UUID:
- connect_opencode.go: a dedicated opencodeForwarder that runs
`opencode run --pure --format json`, accumulates reply text from `text`
events, captures the top-level sessionID, and on the first turn persists the
convID->sessionID map to <config>/connect/<clientId>/opencode-sessions.json
(best-effort, restored on startup). The next message — and a connector
restart — replays it with `--session <id>`. Implements streamingForwarder and
sessionResetter, so /new and /clear work and streaming cards stream.
- forwarderForChannel routes the opencode channel to it.
- connectAgentOptionsPayload reports opencode memory as
"per-conversation-captured"; --agent-memory help now lists codex/opencode too.
Verified end to end against the real opencode CLI: turn 1 captures the session
id, turn 2 with --session recalls a fact from turn 1. Unit tests cover capture +
resume (stub bin), store persistence across restart, and reset.
gemini also exposes --session-id/--resume but could not be verified end to end
here (no GEMINI_API_KEY) and its --resume id semantics are unconfirmed, so it is
left for a follow-up rather than wired blind. qoder's qodercli still has only
--resume (no addressable id) and cannot do per-conversation memory.
Two connector improvements for the linked-bot chat experience, modelled on
codex / OpenClaw connect:
1. Built-in slash commands. A user can type /new (aliases /start, /reset) to
open a fresh session or /clear to wipe the current one, instead of the
message being forwarded to the agent. The command set is fixed (never
dynamically extended) so behaviour stays predictable. Matching requires the
whole message to be the bare command, so a normal question that merely starts
with a slash is forwarded untouched. Costs no agent turn / tokens.
- connect_command.go: channel-agnostic parser + action table (+ tests).
- sessionResetter optional interface; execForwarder (Claude family) and the
codex app-server forwarder both implement it. The main loop intercepts a
command right after the owner-decision interceptor and acks via webhook.
2. Persist codex per-conversation context. codexThreadSessions previously kept
the convID->threadID map in memory only, so a codex connector restart lost
every conversation's context — unlike the Claude-family convSessions store
which already persisted. The thread identity is now the authoritative,
mutex-guarded map persisted to <config>/connect/<clientId>/codex-threads.json
(best-effort, atomic write), restored on startup. An empty clientId keeps it
in memory only, preserving the original behaviour. This also removes a data
race on threadID by moving it off the per-conversation state into the
sessions store.
The opencode channel forwarded to `opencode run` without isolating the
operator's interactive environment. claudecode already neutralises via
`--setting-sources project --strict-mcp-config`; opencode had no equivalent.
When the operator runs a plugin suite that swaps the global default agent at
runtime (e.g. OhMyOpenCode's rotating Greek-codename agents), headless
`opencode run` crashes resolving a default agent that no longer exists
("default agent ... not found"), and the crash trace is sent back to DingTalk
as the bot reply. Verified by reproducing in a clean /tmp dir: plain
`opencode run` crashes, `opencode run --pure` replies normally.
Add --pure to the opencode argvTail so the bot answers in a clean,
plugin-free environment, consistent with the claudecode channel.
Add a generic 'custom' channel plus an --agent-cmd flag so 'dws devapp
robot connect' can forward to ANY headless AI CLI (question appended as
the trailing arg, stdout used as the reply). Onboards tools that aren't
built-in (e.g. LobsterAI) or self-built agents with no code changes:
--agent-cmd forces channel=custom unless --channel is set explicitly,
and auto-detection falls back to custom when DWS_AGENT_CMD is present.
Also (issue #39): print a one-time hint at connect start when neither a
work dir nor a knowledge source is set, pointing at --agent-workdir /
--knowledge-* / --agent-model so the bot can match terminal answer
quality. Quickstart gains matching FAQ entries plus a note that step 3
(robot connect) produces no approval ticket (issue #19).
RoleConfig.confirm_policy was parsed but never acted on. Wire the three
strategies for how OTHERS' action requests are confirmed (the owner's own
requests always auto-run regardless):
- manual (default): ask the owner every time
- auto: run without asking — full trust — still fully audited
- remember: ask once per action verb, then reuse that decision (approve →
auto-run same verb; reject → auto-decline same verb)
- gateDecision(requester, verb) returns auto / ask / reject
- ApprovalRequest.Verb carries the action verb (remember key + audit)
- handleOwnerDecision caches the decision per verb under the remember policy
- applyRoleConfig fills opts.ConfirmPolicy; launchConnector wires + logs it
Tests: auto runs without asking, manual asks, remember reuses the 1st decision.
RoleConfig.allowed_scopes was parsed but never enforced. Now an action whose
product is outside the role's allowlist is refused before it reaches the gate,
so an HR-assistant role can't be made to touch code/drive.
- applyRoleConfig fills opts.RoleScopes from the role's allowed_scopes
- the orchestrator gains allowedScopes + scopeAllows(product); empty = allow all
- handleReply refuses an out-of-scope action up front and tells the requester
it is out of the twin's lane (a capability boundary, not an approval, so safe
to surface); nothing reaches the gate or the owner
- launchConnector wires opts.RoleScopes into both orchestrators and logs the
boundary at startup
Tests: out-of-scope refused (not gated), in-scope proceeds, empty = allow all.
Builds on the text-approval gate with the digital-twin model the owner asked
for: the approval stays strictly between the bot and its owner, and every
action is auditable in DingTalk.
- private owner DM: an action request is no longer posted into the requester's
conversation; the bot DMs the OWNER privately (robot oToMessages/batchSend,
reusing the bot's own credentials) and the requester sees nothing until the
result. Decision matching moved from per-conversation to latestPending, since
the owner replies from their own 1:1 chat, not the request conversation.
- owner self auto-approve: when the requester IS the owner, asking is the
authorization — the action runs immediately with no second confirmation, but
is still recorded (auto_approved=true, decided_by=owner) for audit.
- online-sheet audit: optional --audit-sheet / --audit-sheet-tab appends one
row per terminal action (time, summary, requester, state, decided_by, auto,
error, id) to a DingTalk online sheet via sheet append_rows, reviewable in
DingTalk on any device — beyond the local approvals JSON.
- aiCardClient.sendOTOText: proactive 1:1 robot text send primitive.
- requester gets the final outcome (executed/failed/rejected) by 1:1 message;
the approval process itself is never exposed to them.
Tests: parseDecisionWord, latestPending, private-DM approve/reject,
owner self auto-approve, audit terminal records, classifier wiring intact.
The confirmation gate previously REQUIRED a hand-built interactive card
template (per app, manual button param binding in the card console) — and
without one the gate silently turned off. That manual step is too clunky to
be the default. Add a text-approval mode so the digital twin works with zero
card-platform setup: when --owner-user-id is set but no --approval-card-
template, the bot posts a plain confirmation and the owner replies 「同意」/
「拒绝」 in chat to decide.
- newTextApprovalOrchestrator: gate enabled on owner alone (no card sender)
- text mode is async, NOT blocking: handleReply posts the prompt and returns;
the owner's decision arrives as an ordinary inbound message captured by
handleOwnerDecision (blocking on Await would deadlock the per-conversation
worker that must also process that reply)
- gate.pendingForConv maps an owner reply (carries only the conversation) back
to its request; parseDecisionWord matches a WHOLE-message keyword so an
embedded 「同意」 never accidentally approves
- only the configured owner can decide; non-owner / non-keyword pass through
- devapp_connect: no card template now falls back to text mode instead of
disabling the gate
- existing card flow refactored into handleReplyCard, unchanged behavior
- tests: parseDecisionWord, pendingForConv, text-mode approve/reject,
non-owner rejected, non-decision pass-through
Sprint 2 item 2: a digital-employee role config (connect_role.go) was a
defined-but-unused schema. Wire it into the connector so a single
--role-config <file> supplies the bot's owner, persona and knowledge
sources instead of scattering them across flags.
- add --role-config / DWS_ROLE_CONFIG; load + validate in launchConnector
with a client_id-must-match-this-bot guard (one role == one bot)
- applyRoleConfig merges role -> options with explicit-flag-wins semantics
(a flag/env always overrides the role; knowledge sources are additive)
- owner feeds the approval gate; persona is prepended to every forwarded
prompt; role knowledge sources load and merge into the same retriever
- AllowedScopes / ConfirmPolicy are parsed and logged but not yet enforced
(scope gating + auto/remember strategies are a later slice; no dead opts
fields carried for them)
- tests: applyRoleConfig flag-wins / fill-empty / nil-role
Scope enforcement and confirm-policy behavior intentionally deferred.
Add the M2 confirmation gate (digital twin): an action request from a
group member is not executed directly. The bot sends an [Approve]/[Reject]
card to the owner over the existing Stream long-connection and only runs the
planned command after the owner approves.
- connect_approval.go: gate engine. ApprovalRequest model, Pending to
Approved/Rejected to Executed/Failed state machine, thread-safe store with
crash-safe atomic on-disk persistence (~/.dws/connect/<clientId>/approvals,
0600, restart-recoverable), Submit/Decide/Await/Get API, and the simplified
[[ACTION:...]] marker detection for execution-class requests.
- connect_approval_card.go: approvalCardSender boundary (DI-fakeable), the
card-callback decoder (approval id + decision from action params, OutTrackId
fallback), the orchestrator driving Submit to card to Await to execute to
reply, and the real DingTalk interactive-card sender.
- connect_stream.go / devapp_connect.go: minimal wiring. Register the card
callback router, decorate the prompt for action detection, route a marked
reply through the gate, --owner-user-id / --approval-card-template flags.
Tests: state machine + persistence/recovery, card-action decode, reject path
(no execution), and a no-network end-to-end approve chain. gofmt/vet/build/
test all green.
Extend the connector's knowledge retrieval (previously local .md/.txt only)
to a DingTalk knowledge base via --knowledge-source wiki:<spaceId> (and
doc:<docId>). On startup the connector pulls the space by reusing the existing
`doc` tools that back `dws doc list` / `dws doc read` (list_nodes +
get_document_content) through the shared executor.Runner -- no new DingTalk API
call is implemented. Pulled documents are dumped as markdown into a local cache
(~/.dws/connect/<clientId>/knowledge/wiki-<spaceId>/) and indexed by the same
chunker/retriever as the local-directory source.
Refresh on startup; on a pull failure (network/permission) fall back to a stale
cache if present, otherwise an empty knowledge base -- the connection is never
blocked. The legacy --knowledge-dir source is untouched and coexists.
Node listing uses a shallow id lookup so a response wrapper object is not
mistaken for a single node (which would drop sibling nodes).
convSessions held the DingTalk-conversation to agent-session map purely
in memory, so a connector restart dropped every chat's multi-turn
context - the core weakness of an always-on digital employee.
Persist the map to ~/.dws/connect/<clientId>/sessions.json (scoped per
clientId so multiple bots on one machine stay isolated), reusing the
existing config dir, 0600 FilePerm and lock-id sanitizer conventions:
- newConvSessions restores the map on startup; a missing or corrupt
file degrades to an empty map with a warning, never a panic or a
blocked connection.
- args/reset persist after every mutation, under the existing mutex so
the snapshot is race-free, via a temp-file + rename atomic write.
- saves are best-effort: a write failure only logs a warning and never
blocks message handling.
- an empty path (clientId missing or --agent-memory off) keeps the map
in memory only, preserving the original behaviour exactly.
forwarderForChannel now takes clientId to derive the per-bot store path.
Adds unit tests for reload, corrupt-file degradation, in-memory mode and
concurrent access (race-clean).
issue #14: the "422" seen in the group was not from the DingTalk card API but
from the claude subprocess's model provider, echoed back as the reply.
Root cause:
- claudecode's spec pins --model claude-haiku-4-5-20251001 in both argvTail and
streamArgvTail.
- claudeUserSettingsEnv (issue #10) injects the user's ANTHROPIC_BASE_URL /
ANTHROPIC_AUTH_TOKEN into the claude subprocess. With a third-party provider
relay that has no mapping for that exact haiku pin, the provider returns
"API Error: 422 ...".
- execForwarder.forward used cmd.Output() and returned any non-empty stdout as
the answer without checking err, so the raw 422 text was forwarded to the chat
on every message.
Fix (A + B):
A. In forwarderForChannel, when a custom provider base URL is in effect
(injected via Claude settings or already in the process env) and the user did
not pick a model (--agent-model / DWS_AGENT_MODEL), strip the built-in model
pin so the provider's default model applies. Official-login users keep the
pin; an explicit --agent-model still wins.
B. In forward and forwardStream, treat stdout that starts with "API Error:"
(or a process error) as a failure: return a short, actionable Chinese hint
("...请用 --agent-model <model> 指定模型后重连.") instead of echoing the raw
backend error. Normal replies are unaffected.
Adds unit tests for the haiku-drop wiring, the API-Error guard, and the new
helpers.
The claudecode channel spawns claude with --setting-sources project to
keep the bot persona neutral, but that also drops the user-level
settings.json env block where third-party provider tools (cc-switch
etc.) store ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. claude then
fell back to the official login and replied "Not logged in - Please
run /login" for anyone on a relayed model.
Re-expose that env block as process environment for the spawned CLI
(both one-shot and streaming paths) via the channel envFn hook.
Variables already exported by the operator are not overridden, and
user hooks/plugins still stay out of replies.
Fixes PeterGuy326#10.
Five operational gaps that bite when a connector-backed bot answers
questions in real groups:
- Single-instance lock: one connector per robot per machine (pid file,
stale-lock takeover). Duplicate Stream connections on one clientId get
messages load-balanced between them, so the bot answers intermittently
(verified live).
- Access policy: --allowed-users / --allowed-groups allowlists plus a
per-sender rate limit (--user-rate-limit, default 20/min). Every
message is an LLM call; without this anyone who can reach the bot can
spend on its behalf.
- Per-conversation FIFO queue: same-chat messages run in arrival order
(follow-ups need the previous turn's --resume session; parallel CLIs
racing one session corrupt it); different chats stay parallel.
- Picture messages: resolve the callback downloadCode via robot
messageFiles/download to a local temp file and hand the path to the
agent CLI — error screenshots are the top Q&A inbound and were
previously dropped silently. The claudecode persona now permits
reading attached files.
- --knowledge-dir: lightweight local retrieval (markdown chunking,
latin-token + CJK-bigram scoring) prepends top-k matching chunks to
each question while the agent keeps running from the clean scratch
dir, so replies stay inside DingTalk's AI-assistant response window.
All knobs have env mirrors (DWS_ALLOWED_USERS / DWS_ALLOWED_GROUPS /
DWS_USER_RATE_LIMIT / DWS_KNOWLEDGE_DIR) for service-style runs.
go build ./... clean; go test ./internal/helpers -race passes with new
tests for lock/gate/queue/knowledge/media. test/cli_compat, test/scripts
and test/unit fail identically on the unmodified base (pre-existing
fixture issues).
- ConnectOptions.CardTemplate said an empty value falls back to the public
openclaw template, but the code (newAICardClient/hasTemplate) treats empty
as cards-off, and the explicit "public" alias exists precisely for that
opt-in. Align the comment with the actual contract.
- hermes official-channel steps now warn about the trap verified live: a
devapp robot renders replies through the AI-assistant response window, so
a slow agent's plain-text reply gets the Done reaction (HTTP 200) but never
shows up, leaving the loading card stuck. Point users to enabling AI cards
on the hermes side.
Mirror hermes' semantics: without a configured card template, replies go as
plain text/markdown with the Thinking/Done reaction chips (which need no
template and always render); cards activate only when --card-template /
DWS_CARD_TEMPLATE is set ('public' opts into the openclaw shared template
explicitly). This removes the silent-failure trap where every card API call
succeeds but the client cannot render a template it is not authorized for —
the previous default left users staring at 内容加载失败 instead of falling
back to text.
The channel CLIs (claude/codebuddy/qodercli/...) may be missing on the
user's machine. Runtime already preflights at connect (auto-installing
package-manager CLIs, erroring with a hint otherwise), but agents had no
machine-readable way to check BEFORE starting the connector. dry-run now
reports a cli block ({required, installed, path, autoInstall, installHint})
per channel — desktop-app channels and openclaw/hermes report their
onboarding hint. Skill docs gain the agent preflight workflow: check
dry-run first; npm channels may auto-install; desktop-app channels must
have the app installed by the user before connecting.
Card templates are app-scoped — the hermes docs prescribe grabbing the
template ID from YOUR app's AI Card settings in the developer console, and
our live A/B confirmed another app's template renders 内容加载失败. Expose
the template as a flag (default: the openclaw public template, best-effort)
so operators can register an app-owned template for reliable, branded
rendering. Skill docs updated with the developer-console path.
The hardcoded devapp endpoint was checked BEFORE dynamic registration and
edition static/supplement lookups, short-circuiting every configurable
source — the TestDirectRuntimeEndpoint_Devapp*WithoutRegistry tests encode
the intended priority (built-in = last resort so operators can repoint the
product without a rebuild) and have been failing on the branch. Move the
built-in check after Priority 4; env override > dynamic > PAT > edition >
built-in. Also gofmt two files the Lint job flagged.
Bring the official-channel reply experience (hermes/openclaw) to every
stream-bridge channel:
- "🤔Thinking" reaction chip on the user's message while the agent runs,
swapped to "🥳Done" when the reply lands — the hermes text-emotion contract
(POST /v1.0/robot/emotion/reply|recall, emotionId 2659900).
- AI-card reply using the openclaw connector's public template + payload
contract (msgContent + flowStatus state machine). Card templates are
app-scoped: hermes' own template renders "内容加载失败" for any other app —
confirmed by live A/B on the same robot.
- Live streaming into the card where the channel CLI supports it (verified
per CLI): claude/codebuddy stream-json text deltas (thinking deltas
filtered), qodercli stream-json per-turn snapshots; codex/gemini/opencode
stay one-shot. Frames are full-content and throttled at 800ms per card.
- --reply-card flag (default on, env DWS_REPLY_CARD=0) with full fallback
chain: any card failure → plain text/markdown webhook reply; stuck cards
are best-effort marked failed.
- Deliver responses are checked for business-level failures inside HTTP 200
({"result":[{"success":false,...}]} observed live), and every card API
response body is logged for operability.
- workbuddy persona now forbids tool use: headless codebuddy otherwise
stalls on permission gates trying to write memory files.
- Skill docs: reply-card flag, version-publish approver-selection workflow
(check-approval → present approver list to the user → publish --approver).
Known issue under investigation: after a batch of invalid-template test
cards, the reporting device renders all subsequent cards as "内容加载失败"
even though every API call succeeds and identical payloads rendered earlier
the same day — suspected client-side card/template cache poisoning; pending
verification on a second client.
The SDK's default logger is a doNothingLogger, so the connector printed
nothing on connect/reconnect/read errors — a dead connection was
indistinguishable from a healthy idle one. Wire an ILogger that writes
Info/Warning/Error to stderr (debug frames stay silent). Verified live:
'[stream] connect success, sessionId=...' now appears at startup.
Users reported the linked bot feels dumb. Root cause: the forwarder was
hard-pinned to a stateless one-shot haiku run from an empty directory. Expose
the trade-offs as command-set flags instead of hardcoding them:
- --agent-memory (default on): per-conversation session resume. First message
of a DingTalk conversation mints a UUID and passes `--session-id`,
follow-ups pass `--resume`, so multi-turn context survives. Only on CLIs
with addressable sessions (claudecode/codebuddy/workbuddy — verified against
their --help); qodercli has --resume but no --session-id, so the qoder
family stays stateless. Broken sessions self-heal: on forward error the
conversation mapping is dropped and the next message starts fresh.
- --agent-model / DWS_AGENT_MODEL: override the channel CLI's model via each
spec's model flag (replaces claudecode's built-in haiku pin in place).
- --agent-workdir / DWS_AGENT_WORKDIR: run the agent from a knowledge
directory for context, instead of the clean temp dir (which stays the
default to keep cold-start inside DingTalk's reply window).
A DWS_AGENT_CMD override disables flag splicing entirely — that argv is
user-owned and we cannot know which flags it accepts. Dry-run now previews
the effective agent tuning (model/workdir/memory) per channel.
Bring the channel-aware Stream linking ("建联") from PR #407 into the devapp
command tree as `dws devapp robot connect`, so the full lifecycle
(create app -> create robot -> link to a local agent) lives under one
`dws devapp` domain instead of a separate `dws connect` command.
- Reuse, not duplicate: provisioning ("建号") stays on devapp's existing
`robot create/submit/result`; this commit ports only the linking half.
- connect_stream.go: the Go-native in-process Stream forwarder + channel
agent registry, ported verbatim from #407 (package helpers, self-contained).
- devapp_connect.go: trimmed channel routing (resolveConnectChannel /
launchConnector / buildConnectPlan / ...) plus the new subcommand. The
standalone `connect` cobra command, `connect start` and `connect bot create`
are intentionally NOT brought over — the capability is consolidated under
`dws devapp robot`.
- Credentials: `--robot-client-id` / `--robot-client-secret` for an existing
robot, or `--unified-app-id` to auto-fetch via devapp's credentials get.
The flags are named `robot-client-*` (not `client-id`/`client-secret`) to
avoid shadowing the global OAuth client-override persistent flags.
- Adds the dingtalk-stream-sdk-go v0.9.1 dependency.
- Skill docs (devapp.md, robot.md mono+multi) document `robot connect`.
Known follow-up (marked in code + docs): the get_open_dev_app_credentials
response field names used by `--unified-app-id` auto-fetch are not yet
confirmed against the real gateway; the path degrades safely (empty -> fall
back to explicit flags) and must be verified against pre-prod before relying
on it.