When `dev connect` runs with no credentials (no --robot-client-id/secret, no
--unified-app-id) in a real terminal, it now guides the user instead of failing:
- Ask "new app or existing app?".
- Existing: read a unified-app-id (reuses the credentials-get path) or an
explicit clientId/clientSecret pair.
- New: read the app name, robot display name and description, submit the async
robot-create task, then poll for the result to obtain credentials. This has a
real side effect (it provisions a real robot app).
connect_onboarding.go keeps the flow channel-agnostic and io-injected so the
whole decision tree is unit-tested with a mock runner and scripted stdin (no
network, no real provisioning). Non-interactive invocations — scripts, daemons,
pipes, and --dry-run — are unchanged: they still require an explicit credential
flag, so nothing that worked before starts prompting.
Tests cover: existing/unified, existing/raw-creds, new-app submit+poll (asserts
the submit_robot_create_task / query_robot_create_result calls and params),
invalid choice, and a missing required field (no side-effecting call).
Note: the real provisioning path shares the same unverified credential field
names as devAppFetchCredentials (clientId/appKey, clientSecret/appSecret — see
its TODO(verify)); the live create→credentials flow is to be verified on the
pre-prod gateway with the -dws-devapp prerelease before relying on it.
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.
dev 伞形命令(app/connect/doc)、应用全生命周期、权限/成员/安全/机器人/
版本/事件、cursor 透传分页、pretty 状态标注、connect 建联。MCP 工具名对齐
服务端实际注册名。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
A deferred backlog (queued while the connector's dws login was not yet the
bot owner) now drains by itself once the owner's identity is back, instead of
requiring the owner to manually reply 重试.
- startAutoRetry runs a 2-minute background ticker (text mode) that calls
autoFlushDeferred until the connector context is cancelled
- autoFlushDeferred replays the backlog but messages the owner ONLY when
something actually completed — a tick while the identity is still wrong is
silent (no spam); each completed requester is still notified
- flushDeferred (manual 重试) and the auto path share flushDeferredOnce
- the deferred notice now tells the owner it will auto-recover after login,
with 重试 as an optional 'do it now'
Tests: auto-retry stays silent while stuck, drains + notifies on recovery.
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.
Turn `dws devapp robot connect` into a 7x24 background service.
- `connect --daemon` re-execs dws into a detached supervisor (POSIX setsid),
prints pid + log path, and exits. Windows is unsupported (stub errors out).
- A supervisor process keeps a worker connector alive, restarting it with
exponential backoff (1s..60s cap, 10 consecutive fast-failure ceiling).
- `connect status` reports running/stale/not-running, pid, uptime and log path.
- `connect stop` sends SIGTERM (worker releases the single-instance lock and
Stream connection), escalates to SIGKILL on a timeout, and cleans the pid file.
- stdout/stderr are archived to ~/.dws/connect/<clientId>/daemon.log via the
existing size-based rotator (new additive logging.NewRotatingFile).
- docs/connect-daemon-service.md ships launchd and systemd templates for
boot-time auto-start.
The single-instance lock (connect_lock.go) is reused unchanged. The foreground
forwarding/session/knowledge logic is untouched; devapp_connect.go only gains
the --daemon flag, two hidden re-exec flags, and the status/stop subcommands.
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).
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.
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.
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.