Add shared project and session selection across IM adapters, preserve bindings on
failed restoration, and synchronize permissions with desktop clients.
Fixes#1286
Connecting Feishu meant creating a bot by hand on the open platform and
pasting an App ID and App Secret back. Feishu also exposes an RFC 8628
device-authorization flow, so the desktop can now render a QR code, and
confirming it in the app creates the bot and stores its credentials
directly. `adapters/feishu/registration.ts` implements that protocol
rather than importing `registerApp` from `@larksuiteoapi/node-sdk@1.73`:
the repository pins 1.60 for the chat client, and the SDK runs the whole
poll inside one un-cancellable promise where the desktop needs the
stateless begin/poll pair the DingTalk registration already uses. The
scan is create-only, so it can never rewrite the configuration of a bot
the user already runs, and it pre-fills exactly the scopes, events and
callbacks this adapter calls. International tenants finish on Lark's
domain, which is now persisted and honoured by the client.
WeCom, QQ and Slack join the same session model. WeCom and QQ bind by
scanning; Slack has no scan flow, so it uses an app manifest that
pre-fills the scopes and Socket Mode. All three run over long
connections, so no public callback URL is needed, and all three accept
private chats only — pairing authorizes one person, and answering in a
group would extend that authorization to everyone else in the room.
They are built on a new `adapters/common/chat-runtime.ts` instead of a
fourth copy of the loop the five existing adapters each carry. A platform
supplies a `ChatPort` — how to say something, how to open a streaming
reply, optionally how to send an image — and the runtime owns pairing,
command routing, session restore, permission bookkeeping and the
translation of the server's stream. The existing five are deliberately
left on their own copies; migrating them is a separate change with its
own regression surface.
Attachments are downloaded through a deferred loader that runs after the
pairing gate and inside the per-chat queue. Resolving them eagerly would
let an unpaired stranger make the adapter fetch bytes and write them
under ~/.claude/im-downloads — on Slack with the bot token attached —
and would let a slow attachment overtake a text message sent after it.
The sidecar launcher's per-adapter branches become one table. It is
declared above the mode dispatch on purpose: `runAdapters` is hoisted and
runs at module top level, so a table declared below it is still in its
temporal dead zone when the adapters mode reads it — which type checks,
lints and unit tests all miss, and only the compiled binary reveals.
Verified with the checks `check:impact` selects: adapters, server,
desktop, electron, policy, chat-contract, agent-flow, docs, native
(sidecar compile, packaging and an adapters-mode smoke against the real
binary) and coverage. The scan flows themselves are not verified against
live platforms — that needs real WeCom, QQ and Slack accounts and would
create real bots.
Claude-Session: https://claude.ai/code/session_01CCGoP316AK7wdQG3Ms6Uwq
Since v0.5.1 every IM channel listed only the default project. All five
adapters passed the default work dir to AdapterHttpClient as the sole
allowed project root, so listRecentProjects filtered out everything else;
matchProject, listSessions, sessionExists, createSession and listSkills
were clamped the same way. Feishu is where it was reported, but telegram,
wechat, dingtalk and whatsapp were identical.
defaultWorkDir is documented as where a new IM session starts, not as an
access boundary. Using it as the boundary failed both ways: configured, it
hid every other project; blank, it falls back to PWD/cwd(), which is "/"
for a GUI-launched sidecar, so the boundary allowed the whole filesystem.
Split the two concepts. allowedProjectRoots is now its own setting (global,
per-platform, or ADAPTER_ALLOWED_PROJECT_ROOTS), resolved together with the
work dir by resolveAdapterWorkspace so the default project is always inside
the boundary and /new cannot fail on inconsistent config. The default is the
home directory; it refuses to inherit "/" or any ancestor of home. Pairing
remains the primary authorization control, so unusable roots warn and fall
back rather than locking the bot out.
All five entrypoints now build their client through createAdapterClient
instead of repeating the wiring, which is what let one defect appear in five
places at once.
Known gap, left for a follow-up: a project outside the boundary is still
reported as "not found" rather than "outside the allowed directories".
Found by widening scripts/pr/dead-imports.ts past src/server/ws once its
lexer stopped desyncing. None of these bindings appear anywhere in their own
file; several are the tail of a move that left the import behind, and
useMergedTools still lists replBridgeEnabled in a dependency array after the
useAppState call that set it was stubbed out.
Nine statements lost their last binding. Each target was checked for a
top-level side effect first: node:fs, node:fs/promises and bun:bundle have
none, ws/events.js is a type-only import that never emits, AppState.tsx,
model/model.ts and markdown-style.ts declare only, and utils/config.ts does
register a cleanup but is imported by 157 other files in src, so
attachments.ts dropping it cannot unregister anything.
No behavior change intended and none observed: src is 2995 pass / 37 skip /
30 fail across 273 files before and after, with the 30 failures identical
test for test, and adapters is 437 pass / 0 fail both times. The 30 are
pre-existing and unrelated.
Feishu CardKit reply sends can fail after the platform has already accepted the message. Reusing one outbound UUID across the initial reply send and the create fallback keeps the fallback idempotent instead of risking a second visible message.
Constraint: Feishu im.message.reply and im.message.create both accept uuid idempotency keys
Rejected: Removing the create fallback entirely | would regress CardKit create/send fallback behavior when the original reply truly did not land
Confidence: high
Scope-risk: narrow
Directive: Keep reply fallback sends on the same outbound UUID; do not regenerate it per API attempt
Tested: bun test feishu/__tests__/streaming-card.test.ts
Tested: bun test feishu/__tests__/cardkit.test.ts feishu/__tests__/streaming-card.test.ts
Tested: cd adapters && bun run test:feishu
Tested: bun run check:adapters
Tested: cd adapters && bunx tsc --noEmit
IM adapters could keep in-process bridge state after desktop session deletion removed the persisted mapping or after the server no longer had the mapped session. The restore path now validates the stored mapping against bridge memory and the server session before reusing it, then clears stale transient state before creating a replacement session.
Constraint: Issue #574 is intermittent and tied to adapter-created sessions; live reporter Telegram credentials are not available locally.
Rejected: Keep separate recovery checks in each IM adapter | duplicates the same stale-state logic across Telegram, Feishu, WeChat, and DingTalk.
Confidence: high
Scope-risk: moderate
Directive: Do not use bridge.hasSession() as the sole restore check; verify adapter-sessions state and server session existence first.
Tested: bun run check:adapters (364 pass, 0 fail)
Tested: cd adapters && bunx tsc --noEmit
Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/websocket-handler.test.ts --timeout 30000 (110 pass, 0 fail)
Tested: git diff --check
Not-tested: Live Telegram bot/provider reproduction on the reporter machine
Feishu CardKit and DingTalk AI Card updates run inside the serialized adapter message path. A hung platform update can prevent the later message_complete event from being handled, leaving mobile IM users stuck on a partial streaming card.
This bounds platform card API calls and gives Feishu a final patch fallback when CardKit finalization fails, so a bad intermediate frame cannot permanently block the chat turn.
Constraint: Feishu and DingTalk card APIs are external network calls without reliable SDK-level deadline guarantees
Rejected: Disable streaming cards entirely | loses the live IM experience for normal fast responses
Confidence: high
Scope-risk: narrow
Directive: Keep IM card platform calls bounded because adapter server messages are serialized per chat
Tested: bun test adapters/feishu/__tests__/streaming-card.test.ts
Tested: bun test adapters/dingtalk/__tests__/ai-card.test.ts
Tested: bun run check:adapters
Tested: bunx tsc -p adapters/tsconfig.json --noEmit
Not-tested: Full bun run verify was interrupted during the coverage lane after dependency setup
Text-only IM channels forced users to copy long permission request IDs from mobile chat, which made approval slow and error-prone. This keeps the requestId-based authorization protocol intact while adding short replies for the single-pending-request case and preserving full command fallbacks for ambiguous cases.
Constraint: IM authorization must still resolve through the existing requestId permission_response path
Rejected: Replace request IDs with global numeric IDs | concurrent permission prompts would make numeric IDs ambiguous across chats
Confidence: high
Scope-risk: moderate
Directive: Do not allow short numeric replies when more than one permission request is pending in the chat
Tested: cd adapters && bun test common/ telegram/ feishu/ dingtalk/ wechat/
Tested: cd adapters && bunx tsc --noEmit
Tested: bun run check:adapters
Not-tested: Real WeChat or Telegram account end-to-end message delivery
This captures the pending worktree fixes before applying them to the
current local main. The changes tighten IM adapter path and credential
handling, preserve retry behavior for failed desktop notifications, and
make Azure/OpenAI provider auth and stop reasons reflect actual runtime
state.
Constraint: Worktree was detached from an older local main with pending uncommitted fixes
Rejected: Merge the detached HEAD directly | would also replay unrelated stale history
Rejected: Leave notification dedupe as fire-and-forget | failed sends consumed retry keys
Confidence: high
Scope-risk: broad
Directive: Keep adapter absolute-path matching constrained to configured work roots
Tested: git diff --check
Not-tested: full quality gate before local main integration
WeChat needs a QR-paired path instead of bot-token setup, so the adapter layer now includes the iLink protocol calls, desktop pairing UI, server-side bind/unbind APIs, and shared IM command behavior. Empty project history falls back to the user's default work directory so mobile /new works without pre-opening a desktop project.
Constraint: Tencent iLink login returns a URL that the desktop UI must render as a QR image locally
Constraint: IM adapters should keep /new, /projects, status, permission, and default workdir behavior consistent across WeChat, Feishu, and Telegram
Rejected: Require users to paste absolute project paths for first WeChat sessions | mobile onboarding should work from the default user working directory
Confidence: high
Scope-risk: moderate
Directive: Do not change WeChat polling back to overlapping intervals; getupdates is a long-poll endpoint and must remain serialized
Tested: Real WeChat QR scan, inbound /status, outbound reply, and unbind E2E
Tested: bun run check:adapters
Tested: bun run quality:pr
Not-tested: Re-scan live WeChat after the default-workdir fallback tweak; covered by adapter config tests and PR gate
This bundles the pending desktop/server team-session fixes with the local adapter recovery changes already in the worktree. The team path now keeps teammate membership stable under concurrent spawns, surfaces real teammate identities in the desktop UI, and allows direct interaction with member transcripts. The adapter changes recover automatically when stale thinking signatures invalidate an existing session.
Constraint: Team config writes can happen concurrently while multiple reviewers spawn in parallel
Constraint: Desktop member views must follow mailbox/transcript semantics rather than hijacking teammate runtime sessions
Rejected: Keep relying on config.json alone for member discovery | in-process teammates can be lost after concurrent writes
Rejected: Open teammate sessionIds as normal desktop sessions | would attach a second CLI instead of the running teammate
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Preserve locked team-file mutation for any future teammate registration path and keep teammate labels sourced from member names before agent types
Tested: bun test src/server/__tests__/teams.test.ts src/server/__tests__/team-watcher.test.ts
Tested: cd desktop && bun run test --run src/stores/chatStore.test.ts src/pages/ActiveSession.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Not-tested: Manual end-to-end validation against a live Agent Teams run in the desktop app
Feishu cards used to sit on "正在思考中..." for the entire turn and
then dump everything at message_complete, while Telegram showed the
reasoning and tool calls progressively. Two underlying causes:
1. handleServerMessage ignored `thinking`, `content_start{tool_use}`
and `tool_use_complete` entirely, so nothing was fed into the card
between turns of text output.
2. StreamingCard.performFlush silently set `cardKitStreamActive=false`
on any non-rate-limit error, after which all middle frames were
skipped and only finalize touched the card — exactly the
"long wait → all at once" symptom.
StreamingCard now tracks accumulatedReasoningText and a toolSteps
list, with appendReasoning / startTool / completeTool methods that
trigger throttled flushes like appendText. renderedText composes
tools → reasoning → answer in plain markdown (no blockquotes / lists
that historically tripped Feishu's parser). consecutiveStreamFailures
threshold (3) keeps a single transient error from killing the stream.
terminalText() is used by finalize so the final card holds only the
answer text, matching the Desktop UI's clean post-completion view.
handleServerMessage wires the new events to the existing per-chat
StreamingCard via the streamingCards map (read-only — never
auto-creates cards from thinking/tool events to avoid empty cards
for /clear-style commands).
Test coverage: realistic event-stream regression (thinking → tool_use
→ text → finalize), first-frame-failure recovery, finalize drops
intermediate state, plus per-method tests for the new APIs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Re-implements the inbound and outbound attachment paths on top of the
StreamingCard rewrite (ae586b0) instead of the legacy MessageBuffer
pipeline. Replaces the dangling extractText reference and ports the
behavior originally drafted on feat/im-attachments to the new structure:
Inbound (user → Claude):
- handleMessage now uses extractInboundPayload to detect text and any
PendingDownloads (image/file/file_archive/post-embedded media)
- safeMessageId captured outside enqueue to keep TS narrowing happy
- All command branches gated on `!hasAttachments` so attachment-bearing
messages bypass /new, /clear, /stop, /projects, etc.
- Per-message Promise.allSettled download via FeishuMediaService;
per-attachment checkAttachmentLimit; partial-failure user hint
- AttachmentRef[] forwarded through bridge.sendUserMessage(...)
- effectiveText guard prevents empty content from being sent to Claude
when all attachments are rejected and the user supplied no text
Outbound (Claude → user):
- content_delta handler feeds the streaming text into per-chat
ImageBlockWatcher and dispatches each new PendingUpload via
dispatchOutboundImage (fire-and-forget)
- dispatchOutboundImage handles all three source kinds (base64/path/url),
applies the same checkAttachmentLimit, dedupes by fingerprint, and
publishes the image as an independent im.message.create({msg_type:'image'})
alongside the streaming card text
- Card-embedded `{tag:'img', img_key}` rendering is intentionally deferred
to a follow-up PR — the MVP keeps streaming text and outbound images as
separate messages
Lifecycle: clearTransientChatState and startNewSession now also delete
imageWatchers and uploadedImageKeys for the chat so a new session never
inherits stale fingerprints.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- attachment-store.ts: pin Dirent<string>[] and pass `encoding: 'utf8'`
so newer @types/node doesn't infer Dirent<NonSharedBuffer>
- feishu/media.ts: drop Readable.from(buffer) and pass Buffer directly
to im.image.create / im.file.create — Lark SDK already accepts Buffer
and the stream wrapper trips the stricter Buffer | ReadStream union
used on the main branch's tsconfig
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace legacy im.message.patch streaming path with a Feishu CardKit
Schema 2.0 pipeline mirroring openclaw-lark's implementation. Desktop
now renders real formatted markdown (headings, tables, code blocks)
instead of literal text, and streaming output updates per chat without
cross-talk.
New modules:
- card-errors.ts: structured Lark SDK error parsing for code 230020
(rate limit -> skip frame) and 230099/11310 (table limit -> disable
CardKit streaming and fall back to settings+update at finalize)
- flush-controller.ts: mutex + needsReflush + long-gap batching
throttler for per-chat flush coordination
- cardkit.ts: thin wrapper over client.cardkit.v1.* (card.create +
im.message.{create,reply} + cardElement.content + card.settings +
card.update), with monotonic sequence and CardKitApiError
- streaming-card.ts: per-chat StreamingCard state machine covering
idle -> creating -> streaming -> finalizing -> completed/aborted,
including patch fallback when CardKit create/send fails
index.ts rewrite:
- replaces MessageBuffer/chatStates/buffers maps with a single
streamingCards Map<chatId, StreamingCard>
- content_start{text}/content_delta create or reuse the card; finalize
runs on message_complete; abort renders a red error card
- /clear and other silent commands stay out of the streaming-card path
so they don't leave empty cards
- all command handlers + normal chat are wrapped in the per-chat
enqueue() serial queue, preventing reply reordering when commands
are fired rapidly
- createSessionForChat() now first resets any stale WS session before
calling connectSession, fixing /projects pick_project leaving the
old session bound to the previous workDir
- normal messages pre-create the card before sendUserMessage so users
see a "☁️ 正在思考中..." loading indicator immediately
- content_start{tool_use} no longer finalizes the current card, letting
one user turn's full text stay in a single card
markdown-style.ts:
- default cardVersion changed from 1 to 2 (Schema 2.0)
- headings H2~H6 demoted to H5, H1 to H4, only when source has H1~H3
- Schema 2.0 <br> padding around headings, tables, code blocks
- stripInvalidImageKeys() drops non-img_* image references
- FEISHU_CARD_TABLE_LIMIT=3 + sanitizeTextForCard() wraps 4th+ table
in a fenced code block to avoid 230099/11310
Tests: 182 pass, 0 fail, 383 expect() calls across 6 files. tsc clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Carries forward ~800 lines of in-flight work that existed in the working
tree before the IM-attachment branch was cut. Kept as a single isolated
commit so later attachment work has a clean diff baseline and so this
WIP can be cherry-picked back to main on its own merits.
Contents:
- ws-bridge: per-chat handler chain serialization; sendPermissionResponse
now accepts optional `rule: 'always'` for persistent permits
- feishu/index.ts: permission card rewritten to Schema 2.0 with icon,
cross-dir warning, and ♾️ 永久允许 button; optimizeMarkdownForFeishu
applied to streaming card
- feishu/markdown-style.ts: new Feishu-specific markdown downgrader
- tests: corresponding bun:test coverage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Resolve bot open_id via /open-apis/bot/v3/info — the SDK's
contact.user.get({user_id: 'me'}) path is invalid on Feishu and
returned 99992351 on every startup.
- Stream replies via interactive cards instead of post messages:
im.message.patch only accepts msg_type=interactive, so the old
path-rich-text placeholder caused "This message is NOT a card"
(230001) on every flush. Added buildStreamingCard helper.
- Add project picker card: one column_set row per project with
[info (name + branch + path) | select button], Schema 2.0,
mobile-friendly path display (~ substitution + middle-truncation),
header subtitle with project count. pick_project actions route
through the existing handleCardAction.
Card patterns verified against openclaw-lark's Schema 2.0 usage in
src/tools/oauth-cards.ts and src/card/builder.ts. 34 unit tests
cover the new card structure and action routing.
This folds together the desktop-side fixes needed before broader rollout.
Session resume no longer deadlocks waiting on init, Mermaid and inline image
output render inside chat, task and sub-agent state stay visible during
execution, local build/release paths are safer, and Feishu/Telegram now expose
lightweight mobile commands (/help, /status, /clear) without adding a new
adapter-specific protocol.
Constraint: Desktop releases must publish updater artifacts from non-draft GitHub releases
Constraint: IM commands need short, phone-friendly responses and low operational complexity
Rejected: Add a dedicated IM command API surface | re-used existing slash commands and session/task REST endpoints to keep adapters thin
Rejected: Wait for task_update push events in WebUI | added low-risk polling because the current frontend ignores that event path
Confidence: medium
Scope-risk: broad
Reversibility: clean
Directive: Keep IM command replies terse and mobile-first, and merge local fallback slash commands when server-provided lists are partial
Tested: cd desktop && bun x vitest run src/components/chat/MermaidRenderer.test.tsx src/components/markdown/MarkdownRenderer.test.tsx
Tested: cd desktop && bun x vitest run src/components/chat/composerUtils.test.ts src/pages/ActiveSession.test.tsx src/stores/chatStore.test.ts
Tested: cd desktop && bun run lint
Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "SDK init arrives only after the first user turn" --timeout 60000
Tested: cd adapters && bun test common/ feishu/ telegram/
Tested: cd adapters && bunx tsc --noEmit
Not-tested: Full GitHub Actions release run on all three desktop platforms
Not-tested: Local DMG packaging end-to-end on Apple Silicon
Not-tested: Real Feishu/Telegram device sessions against a live adapter process
- Add matchProject() to http-client for fuzzy project matching by
index or name
- Extract startNewSession() in both adapters to share reset + match +
create logic
- Project selection replies now go through startNewSession(), creating
a clean new session instead of continuing the old one
- Support: /new 2 (by index), /new backend (by name), /new (default)
- Add usage hint in /projects list output
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When all buffered text has already been flushed via periodic timer,
buf.complete() calls flush(true) which returns early on empty buffer,
skipping the onFlush callback that cleans up placeholder state.
This causes subsequent messages to keep editing the old completed
message instead of creating a new one.
Add explicit placeholder cleanup in message_complete handler as
a safety net, matching the same pattern used for tool_use finalization.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix WebSocket race condition: wait for connection to open before
sending first message, preventing silent message loss after pairing
or project selection
- Fix response text appearing above tool calls: finalize placeholder
before tool_use blocks so post-tool text gets a new message
- Remove noisy tool_use and tool_result messages from IM output;
thinking indicator is preserved, details visible in Desktop
- Fix /new command using default project dir instead of always showing
project picker
- Fix duplicate projects in list by deduping on realPath instead of
projectPath
- Improve formatToolUse with human-readable summaries for common tools
- Add send failure feedback to users
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rate limit: max 5 failed pairing attempts per user per 5 minutes
- Default closed: reject all users when no allowedUsers/pairedUsers configured
- Extract isAllowedUser() to common/pairing.ts to eliminate duplication
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement IM adapters allowing users to chat with Claude Code from Telegram
and Feishu/Lark. Includes persistent session management (chatId→sessionId
mapping), project selection via /projects command, and a web UI settings page
for configuring bot tokens, allowed users, and default project directory.
Key changes:
- adapters/: Telegram and Feishu adapter scripts with shared common modules
(WsBridge, MessageBuffer, SessionStore, HttpClient, config, formatting)
- Backend: adapterService + REST API (GET/PUT /api/adapters) with secret masking
- Frontend: AdapterSettings page in Settings tab with i18n support
- DirectoryPicker: use React Portal for dropdown to fix overflow clipping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>