scv-cli 0.3.1

A small, extensible terminal agent runtime with a TUI and headless server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
# Channels

Status: supported local bridges for the SCV daemon

A channel connects chat accounts to a local workspace. One command manages
every channel: `scv channels <command> <channel>`. The channels are WeChat,
through its ClawBot iLink HTTP API, and Feishu with its international edition
Lark, through a bot app and Feishu's event long connection (`lark` is accepted
wherever `feishu` is).

Each account runs as a supervised component inside the single SCV daemon,
which remains authoritative for sessions, provider selection, policy, and turn
execution. `scv-channels` holds the bridge every channel shares and, in a
module behind a Cargo feature of the same name, each channel's transport
(`wechat`, `feishu`; both on by default), which supplies only receiving and
sending. `scv-server` runs accounts through `scv_channels::run`; the crate
uses `scv-client` and `scv-protocol` and never depends on the server crate.

## User workflow

```text
scv channels login wechat [--account NAME] [--login-url URL]
scv channels login feishu|lark [--account NAME]
scv channels login feishu|lark --app-id CLI_ID [--owner-open-id OPEN_ID] [--account NAME]
scv channels run <channel> --workspace PATH [--account NAME] [--remote-tools none|owner] [--senders owner|anyone]
scv channels stop <channel> [--account NAME]
scv channels status [<channel>] [--account NAME]
scv channels logout <channel> [--account NAME]
scv reload
```

`--account` defaults to `default`, except for `status`, which lists every
channel and account unless narrowed. `--login-url` is WeChat's iLink login
origin (default `https://ilinkai.weixin.qq.com`); `--app-id` and
`--owner-open-id` are Feishu's, and each channel refuses the other's options.
Daemon status names each account `<channel>:<account>`, such as
`wechat:default` or `feishu:default`, with a `channel` field, the bot's
identity in `bot_id` (the iLink bot, or the Feishu app ID), and the owner in
`user_id`.

`login` is explicit: it renders the QR code and stores credentials without
printing the token or secret. For Feishu, the scan creates a bot app in the
user's own Feishu or Lark account (see [Feishu contract](#feishu-contract)). Saved accounts autostart under the daemon by default;
logging in preserves any saved disabled setting. Changing the account identity
or API origin requires explicit logout first, including replacing legacy
credentials whose identity is unknown. Login requests a reload when
the daemon is reachable; otherwise enabled accounts start on its next startup.

`run` validates the account and workspace, persists enablement through the
live daemon, and returns. `--remote-tools` persists the account's remote tool
authority and `--senders` whose messages it answers (see
[Sessions and safety](#sessions-and-safety)); omitting either keeps the saved
value. `stop` persistently disables the account and joins
its component while retaining credentials. `logout` requires a live daemon:
it persists disablement, cancels and joins the component, then removes local
credentials, delivery state, and the account's `[channels]` table. The API has no documented remote
token-revocation operation.

`status` queries the running daemon, showing its PID/version, a
`Channels: <connected> of <enabled> enabled accounts connected` line, and each
selected account as an indented JSON object: its identity, enabled setting,
effective `remote_tools` authority, `senders` setting, health state, restart
count, sanitized error, and last successful contact: an
authenticated, validated WeChat `getupdates`, or for Feishu a connected long
connection that finished its catch-up or its last wait without error. An
account that answers only its owner but has no owner ID on record gets a note
that it answers nobody. `scv
status` prints the same for every account.
Saved credentials are not proof of a connection. If the daemon is unavailable,
connectivity is unknown.

## Lifecycle and settings

The daemon reconciles saved accounts on startup and every two seconds;
`scv reload` triggers immediate reconciliation. It starts each enabled account
once, and stops and joins an old instance before starting a replacement with
updated credentials or settings. Unexpected exits retry with exponential
backoff from 1 to 60 seconds. SIGTERM and Ctrl+C cancel and join components and daemon sessions with
bounded shutdown. All long-running integrations use server supervision.

Each account's settings are a table in the instance's `config.toml`
(`$SCV_HOME/config.toml`, `SCV_HOME` defaulting to `~/.scv`):

```toml
[channels.wechat.default]
enabled = true
workspace = "/absolute/path/to/workspace"
remote_tools = "none"
# senders = "anyone"        # answer every sender; omitted, only the owner
```

A missing table or key defaults to enabled, tool-free, and answering only the
account's owner (`senders = "owner"`). SCV writes `senders` only when it is
`"anyone"`, so a table it edits stays readable by releases before the setting,
which reject it as unknown. An omitted workspace
uses the daemon workspace. `run --account NAME --workspace PATH` persists an
explicit workspace. To opt out offline, set `enabled = false` in the table
before starting the daemon. Login honors this opt-out. `scv channels run`,
`stop`, and `logout` edit only their own table, keeping the rest of the file
and its comments; a person's own edit takes effect at the next reconciliation.

Settings reject unknown fields. The supervisor reads credentials and settings
together through `state::account_snapshot` under a short transaction lock.
A busy snapshot defers that account's reconciliation to a later pass without
stopping its current instance. Invalid settings, or a `config.toml` that does
not parse, fail the account closed; `scv config show` names the problem.
Discovery lists `credentials/<channel>/`, rejecting more than 128 entries
and directory-entry errors rather than silently returning a partial account set.
Each channel is discovered separately: one that fails stops only its own
accounts and reports a `<channel>:discovery-error` component, while the other
channel's accounts keep running.

Stop any `0.1.9` standalone ClawBot process before enabling a supervised account.
Those older processes do not honor the account locks.

## Identity and durable state

`credential_fingerprint` binds delivery state to a SHA-256 fingerprint of the
account's identity. For WeChat that is the normalized API origin and
authenticated bot/user IDs; for Feishu it is the brand, the app ID, and the
owner's `open_id`, so a rotated app secret keeps the state while another app
or owner needs a logout. Token rotation for the
same known identity and origin preserves the cursor, pending replies, and
per-chunk client IDs. If either ID is unavailable, the conservative fingerprint
also includes the token and available identity fields. Legacy unbound state is
bound to the saved credentials before first use.

A binding mismatch fails before polling, interrupted-work recovery, or delivery.
Login rejects a different identity/origin, including a transition from legacy
unknown identity to identified credentials, until explicit logout. Login never
resets or archives a running account's state. Logout discards the old state
after stopping the component; a subsequent login starts fresh.

A lifetime advisory lock (`state/channels/<channel>/<account>.lock`) prevents
cooperating runners from using the same account concurrently. A separate
transaction file lock (`<account>.transaction` beside it) serializes login
credential writes, settings/state writes, binding, and removal.
State writes recheck the binding, preventing a stale runner from overwriting
another identity's state. These locks are nonblocking: contention returns a
retry error, and network I/O never holds the transaction lock. The daemon's
account commands (enable, disable, settings, logout) retry that error for up to
five seconds, so they wait out a running bridge's state commit instead of
failing. The bridge's own state writes wait out a command's transaction the
same way, and receiving, delivering, running turns, and storing notices keep
going while one of them waits. Lock files remain in place after logout so open
descriptors cannot refer to different lock inodes.

## WeChat iLink contract

Use the returned `baseurl` after login, falling back to
`https://ilinkai.weixin.qq.com`. Login uses `GET
/ilink/bot/get_bot_qrcode?bot_type=3`, then `GET
/ilink/bot/get_qrcode_status?qrcode=...` until confirmed, expired, or timed
out. Confirmation must provide `bot_token`, `ilink_bot_id`, and
`ilink_user_id`.

Authenticated calls use JSON, `AuthorizationType: ilink_bot_token`, bearer
authorization, and a fresh `X-WECHAT-UIN` containing base64 of a random `u32`.
Bodies include `base_info.channel_version = "1.0.0"`. Error responses with
`ret != 0` or a non-zero `errcode` are converted to redacted bridge errors.
Successful `getupdates` responses from the live iLink API omit `ret` and are
accepted only when they contain an array `msgs` field and a string
`get_updates_buf` cursor. Successful `sendmessage` responses also omit `ret`
and need not be JSON: any 2xx body without a non-zero `ret` or `errcode`
acknowledges delivery.

`POST /ilink/bot/getupdates` long-polls with the opaque `get_updates_buf`
cursor. Only inbound user messages (`message_type = 1`) with sender ID,
message ID, and context token are accepted, and only when they carry text or a
file (see [WeChat media](#wechat-media)); anything else, such as tool-call
items, is only marked seen. iLink message IDs may be strings up to
256 bytes or unsigned 64-bit JSON integers; SCV preserves either form as an
exact string for durable deduplication. Ignored messages are durably marked.
A message's `create_time_ms` (Unix milliseconds, a number or numeric string)
is kept as when it was sent, which only
[questions to the owner](#questions-to-the-owner) use. The field comes from
the message type in Tencent's iLink client and ports of it, and a message
without it never answers a question. Checked live with the owner on
2026-09-26: a WeChat `是` sent after the question arrived carried it and
answered the question.
Before queueing accepted work, the bridge persists an in-flight claim with the
message ID, recipient, context token, and conversation, and it saves the
batch's cursor only after every claim in the batch is durable. Recovery never
resubmits interrupted claimed work; it records a short failure reply for each
claim instead. Completed work becomes a durable pending reply in the same state
write that clears its claim. This prevents a crash between execution and reply
storage from replaying the turn. A message that is already claimed or awaiting
delivery never starts a second turn.

Poll batches above 4096 messages are rejected before executing any message or
advancing the cursor. Deduplication retains the newest 4096 IDs; previously
seen IDs encountered in an accepted batch move to the newest end. This retains
the processed batch until its cursor checkpoint, including recovered replies.

`POST /ilink/bot/sendmessage` echoes the original
`context_token`, uses `message_type = 2`, `message_state = 2`, and a unique
`client_id`. Checked live with the owner (2026-09-23):

- A `sendmessage` with no `context_token` is an unprompted message, answering
  nothing: it returned HTTP 200 with `{"message_id":…}` and no `ret`, and was
  delivered. It was sent seconds after the owner's last message; whether
  iLink allows one after a long silence is untested.
- The first reply on a `context_token`, sent 120 seconds after the inbound
  message, returned 200 with a `message_id` and was delivered.
- A second send on the same `context_token` returned 200 with a `message_id`,
  exactly like a success, but was silently dropped. The API cannot tell it
  apart from a delivery.

So the bridge sends at most one message per context token: a reply longer
than one message goes out as the reply followed by unprompted continuations,
and a background report (below) is always unprompted. A silently dropped
unprompted message cannot be detected either. Pending sends retain that client ID for retry after transport
failures, 5xx responses, and HTTP 401, 408, or 429. A rejection is final: an
explicit `sendmessage` refusal (non-zero `ret` or `errcode` in a 2xx body) or
any other 4xx status. Live iLink keeps refusing the same reply, including after
it already accepted an earlier copy, so the bridge logs the sanitized status or
integer `ret`/`errcode` and bounded `errmsg`, stops sending that reply, marks
the message handled, and resumes polling.

A refused reply is not lost. The bridge holds it, never logging its content,
and delivers it with the next reply to the same conversation (direct chat, or
group and sender), marked `[Earlier reply that could not be delivered at the
time]` and followed by `[Reply to your latest message]`. iLink accepts one reply
per inbound context token, so held replies ride inside that single message:
the oldest that fit beside the new reply go first, and the rest wait for the
following one. A held reply is cut to half the message limit, and the store
keeps at most 4 replies and 32 KiB per conversation, 128 in total, for 7 days,
discarding the oldest first and logging only how many it discarded. If the
carrying reply is refused too, the carried replies return to the store ahead
of it. The busy and voice notices described below are never held. Text replies are split at
Unicode boundaries into messages of at most 16 KiB; only the first carries the
context token. A turn's answer is kept to 64 KiB, and a longer one is cut with
a `[reply truncated]` note instead of failing the turn. Typing indicators are
not sent.

### SCV's own messages

WeChat shows every message from the bot the same way, so on WeChat each
message SCV writes itself, rather than the model, goes out as a Markdown code
block, which WeChat renders apart from the model's plain-text answers. The
block's first line starts with the label `system msg: ` (one space after the
colon):

````text
```
system msg: SCV restarted to update to v0.3.1 before finishing this; ask again if you still need it.
```
````

That covers:

- the busy notice, the voice reply, and the short reply to a message with
  nothing SCV can read (see [Media]#media);
- the failure reply for a failed or timed-out turn, "SCV could not report a
  finished background job." for a failed report turn, and "SCV completed
  without a text response." for an empty answer;
- the reply to work a planned restart interrupted, the notice of stopped
  background jobs, and every notice and update announcement the daemon sends
  (see [Restarts and notices]#restarts-and-notices);
- a question to the owner and each of its outcomes (see
  [Questions to the owner]#questions-to-the-owner).

The model's answers go out exactly as written: turn replies and background
reports, every continuation part of a long one, the held-reply headers, the
`[reply truncated]` and `[N attached files could not be sent]` notes inside
them, and files sent with `chat_attach`. SCV never escapes, strips, or
rewrites the model's output, even an answer that starts with `system msg: `
or holds code blocks of its own. Feishu shows SCV's words unchanged, with no
label and no block.

SCV's words are never changed either. The fence is one backtick longer than
the longest run of backticks in the text, and at least three, as CommonMark
requires, so nothing is escaped or stripped, and a question whose asker wrote
a code block into it stays whole inside SCV's block.

The bridge builds the block once, when it queues the message, and stores the
result as the message's text. The block therefore counts toward the 16 KiB
of a part, a retry resends the same bytes with the same client ID, and a
refused message that is held is carried by a later reply as that one block
(SCV's own messages stay well under the 8 KiB a held reply is cut to). When
SCV's own reply to a message, such as the failure reply, carries held
replies, its own part, after `[Reply to your latest message]`, is the block,
and the carried answers are not.

A message too long for one part is cut on character boundaries into complete
blocks, one per part, and only the first has the label. Each part but the
last is exactly 16 KiB, its closing fence line padded with the spaces
Markdown ignores there, so the ordinary splitter, which every message goes
through, cuts exactly between blocks. A text whose backtick runs are too long
for two fences and a character to fit in a part, which SCV's own words never
hold, goes out after the label without a block.

Delivery state has no new field, and stored text is always sent as stored:
a message queued before SCV used the block, with the bare label or none,
goes out as it was queued, and a release without the block, 0.3.0 included,
sends a stored block as it is. The owner's answers to a question are read
from the owner's own messages, and nothing matches the question's sent text,
so the block changes nothing there. The block is a visual cue, not proof of
where a message came from: the model's answer goes out as written and can
look the same (see [security](security.md)).

## Feishu contract

Checked live with the owner on 2026-09-24:

- The app works as soon as the scan completes, with no developer console,
  administrator approval, or public URL. It is named "<user name>的飞书 CLI";
  renaming it needs the developer console and a new app version, and no
  registration field or API that sets the name was found.
- Its availability range is the scanner alone: other members of the tenant
  cannot find or message the bot until a new app version (or the tenant's
  admin console) widens it.
- Messages SCV starts reach the owner before the owner has ever written to the
  bot and after long silences; there is no reply-token limit.
- Resending a reply with the same `uuid` returns the same message ID without a
  duplicate.
- Messages sent while SCV is disconnected are not redelivered over the socket,
  but the chat's message list returns them, which is why catch-up exists.
- The app is also subscribed to `im.message.message_read_v1`, which SCV
  acknowledges and ignores.
- Not yet checked: group chats, and company tenants whose administrators must
  approve apps.

**Sign-in by scan.** `scv channels login feishu` runs the device flow that
Lark's own CLI uses. It posts forms to
`https://accounts.feishu.cn/oauth/v1/app/registration`: `action=init` must list
`client_secret` in `supported_auth_methods`; `action=begin` with
`archetype=PersonalAgent`, `auth_method=client_secret`, and
`request_user_info=open_id tenant_brand` returns a device code, a user code,
an interval, and an expiry (an hour live). SCV prints a terminal QR code for
`https://open.feishu.cn/page/cli?user_code=…` (`open.larksuite.com` for
`lark`) and polls with `action=poll`: `authorization_pending` keeps waiting,
`slow_down` adds five seconds (at most 60 between polls), `access_denied` and
`expired_token` stop, and other errors stop with only their code shown. When
`user_info.tenant_brand` names Lark, polling moves once to
`accounts.larksuite.com`, which issues the credentials. The result is the app
ID and secret and the creator's `open_id`, recorded as the owner; Feishu issues
`open_id` per app, so it is the sender ID of the owner's messages to this bot.
The app works at once, with no developer console, administrator approval, or
public URL. It is named after its creator ("…的飞书 CLI"); login prints the
developer-console link for renaming it, which needs a new app version.
Registration ignores unknown fields, so whether it can take a name is unknown.
An account that is already signed in must log out first.

**Sign-in with an existing app.** `--app-id` adds an app the user already has,
such as one a company administrator approved. The secret is read from a
hidden prompt or stdin, never an argument, and checked against
`/open-apis/auth/v3/tenant_access_token/internal` before anything is written.
`--owner-open-id` names the owner; without it the account grants tools to
nobody and, unless it answers anyone, answers nobody. Login says so.

**Receiving.** Each connection starts with
`POST {open}/callback/ws/endpoint` (`AppID`, `AppSecret`), which returns a
`wss` URL and client settings. The URL must be on the brand's domain
(`feishu.cn` or `larksuite.com`) over TLS on port 443; any other host is
refused before dialing, and connection errors never include the URL, which
carries one-time keys. Frames are protobuf `pbbp2.Frame`
(`crates/scv-channels/proto/pbbp2.proto`). SCV pings the connection's
`service_id` at once and then at the server's `PingInterval` (90 seconds
live), applies intervals a pong reports, and treats two intervals plus 30
seconds of silence as a lost connection. Events split by the `sum` and `seq`
headers are reassembled within 30 seconds, 64 parts, and 8 MiB. Only `event`
data frames are handled; `im.message.receive_v1` becomes a message and every
other event, such as `im.message.message_read_v1`, is acknowledged at once and
ignored. A message event is acknowledged, with the same frame, a `biz_rt`
header, and a `{"code":200}` payload, only when the bridge asks for the next
batch, which it does after the event's claim and checkpoint are durable.

**Catch-up.** Feishu does not redeliver messages sent while SCV was
disconnected. The checkpoint records, for up to 64 chats, whether each is a
group and the newest message time handed to the bridge. On every connection,
before reading the socket, SCV lists each of the 32 most recently active
chats' messages since that time, reaching back at most 24 hours, through
`GET /open-apis/im/v1/messages` (`container_id_type=chat`, oldest first, up to
four pages of 50), and hands them over like socket events. The bot's own and
other apps' messages and deleted ones are skipped. A chat whose history Feishu
refuses is skipped with a warning; a transport failure fails the catch-up so
it runs again. Deduplication by message ID drops anything already claimed or
answered, including a late socket redelivery. Messages from chats SCV has not
yet seen are not caught up.

**Messages.** Every kind of user message is answered, from whoever the
account answers (see [Sessions and safety](#sessions-and-safety)), except
`system` messages, which are only marked seen; see [Feishu media](#feishu-media) for
files, quotes, and forwarded messages. A message's own `create_time` (Unix
milliseconds, in socket events and chat history alike) is kept as when it was
sent, so a caught-up message keeps its original time; only
[questions to the owner](#questions-to-the-owner) use it. Rich text (`post`) becomes plain text,
one paragraph per line. In a group (any `chat_type` other than `p2p`) the bot answers
only messages that mention it, identified by its own `open_id` from
`/open-apis/bot/v3/info`; without that ID, group messages go unanswered.
Mention placeholders become `@name`, and the bot's own mention is dropped.

**Sending.** Calls use a tenant token cached until ten minutes before it
expires and dropped whenever Feishu reports it invalid. A reply goes to
`POST /open-apis/im/v1/messages/{message_id}/reply`; a message that answers
nothing, such as a background report, goes to
`POST /open-apis/im/v1/messages?receive_id_type=open_id`. Both send `text`
with the part's stable client ID as `uuid`, which Feishu uses to deliver a
resent part once within an hour. Feishu has no single-reply limit, so every
part of a long answer replies to its message. `<at` in outgoing text gets a
zero-width space, so model output can never mention anyone, including `@all`.
Transport failures, HTTP 5xx, 408, and 429, invalid-token codes, and rate-limit
codes (99991400, 230020, 11232, 11233) retry with the same `uuid`; any other
error code is a final refusal, logged by code only, and the reply is held as
for WeChat.

**Untested:** group chats, and sign-in from company accounts whose
administrators must approve apps.

## Media

Chat users can send pictures, voice messages, videos, and files, quote
earlier messages, and forward bundles; the owner's agent can send files back.

Checked live with the owner on 2026-09-26 (0.3.0), in WeChat and in Feishu:
a photo, a file, and a video each reached the owner's model, which described
or read them; a WeChat voice message was answered from iLink's transcript; a
Feishu voice message got the fixed voice reply; and a file the agent sent back
with `chat_attach` arrived in the chat.

**Receiving.** The transport turns each message into text, with a marker for
content that has no file (such as `[sticker]` or `[location: Office (31.2,
121.5)]`), and a list of files. Before the message's turn, within its time
limit and turn slot, the bridge fetches what the message refers to (a quoted
message, or a forwarded bundle's messages, shown before the text) and
downloads at most 16 files. Each download is bounded by the account's
[media settings](configuration.md#daemon-and-component-settings): the owner's
files up to `owner_max_mib` (50 MiB), and, on an account that answers anyone,
other senders' images up to `others_image_max_mib` (5 MiB) and never their
other files. The
platform's announced size is checked before downloading and the bytes while
downloading. A file is saved under
`$SCV_HOME/state/media/<channel>/<account>/<conversation>/` as
`<random prefix>-<sender's name>`, with the name stripped of directories,
control characters, and leading dots; files are mode `0600` and directories
`0700`. The conversation directory is a digest of the conversation, so sender
IDs never become paths. Nothing downloaded is ever executed. The type comes
from the platform's declared type, then the file's magic bytes, then its name.

The saved files go to the turn as `turn.start` attachments (see
[protocol](protocol.md#turnstart)): the model sees images directly when it
accepts image input, and the owner's model sees every file's path, so it can
read it or hand it to an agent. A file that is not downloaded leaves a note
for the model, such as `[file a.zip: not opened for this sender]`,
`[image: download failed]`, or `[video clip.mp4: not downloaded, larger than
the 50 MB limit]`. A voice message with the platform's transcript keeps it,
in the attachment or in the note. A message with no text, no downloaded file,
and no voice transcript gets a short reply instead of a turn, such as `SCV can
read text and pictures from you here, but not a file.` (on WeChat, like the
voice reply below, in SCV's `system msg: ` code block; see
[SCV's own messages](#scvs-own-messages)).

The model cannot listen to audio, so a voice message that carries no
transcript and no text is answered as soon as it arrives with "SCV cannot
listen to voice messages yet. Please type your message instead." (on WeChat,
a code block holding "system msg: SCV cannot listen…"): no download, no
daemon session, and no
model turn. That is every Feishu voice message, which comes without a
transcript, and a WeChat one whose iLink transcript is missing. Like the
busy notice, the reply is queued as a durable pending delivery on the
message's own reply handle, retried with the same client ID, and never held
if the platform refuses it; the message is then marked seen like any
answered one. It goes to whoever the account answers
(see [Sessions and safety](#sessions-and-safety)), and needs no turn slot.

Files and copies of sent files are removed after `keep_days` (7), checked
when the account starts and hourly.

**Sending.** In an owner's session the model can call
[`chat_attach`](tools.md#sending-files-to-a-chat-chat_attach), which copies
a checked file into `$SCV_HOME/state/media/outbox`. The bridge reads
attachments from the turn's `tool.completed` events, keeps at most 8 regular
files that are inside the outbox, and records them in the reply's durable
pending delivery, each with its own stable client ID. It sends them after the
text parts, in order, recording progress after each, so a restart resumes
with the next file and resends an interrupted one with the same client ID. A
file the platform refuses is skipped and logged; a refused text drops its
files. Each copy is deleted once sent or skipped. Captions go into the reply
text, and files beyond the limit, or outside the outbox, are dropped with a
`[N attached files could not be sent]` note. Background report turns may
attach files too.

### WeChat media

iLink message items have a type: 1 text, 2 image, 3 voice, 4 file, 5 video.
Media items point at the CDN (`https://novac2c.cdn.weixin.qq.com/c2c`): a
`full_url`, or an `encrypt_query_param` for `…/download?encrypted_query_param=`,
and an AES-128 key, either `image_item.aeskey` (hex, preferred for images) or
`media.aes_key` (base64 of the 16 raw bytes, or of their 32 hex digits). The
CDN stores files encrypted with AES-128-ECB and PKCS#7 padding. SCV downloads
only over HTTPS from `qq.com` hosts, decrypts, and checks the padding. Voice
items give their encoding (`encode_type` 6 is SILK, 5 AMR, 7 MP3, 8 Ogg) and
often iLink's transcript (`voice_item.text`); SCV keeps the audio as it is
and passes the transcript. A voice message without one gets the voice reply
described above instead. A quoted message (`ref_msg`) becomes
`[Quoting: <title> | <text or [image]>]` before the text, and its file, if
any, is downloaded like the message's own. File names come from
`file_item.file_name`.

To send, SCV reads the outbox copy, picks the upload type (1 image, 2 video,
3 file), and asks `POST /ilink/bot/getuploadurl` for an address with a random
file key and AES key, the file's MD5 and sizes, and `no_need_thumb`. It posts
the encrypted bytes to the returned `upload_full_url` (or builds
`…/upload?encrypted_query_param=…&filekey=…`), again only HTTPS on `qq.com`,
and takes the CDN's `x-encrypted-param` reply header. Then `sendmessage`
sends an `image_item`, `video_item`, or `file_item` pointing at it, with the
key's hex digits in base64 as `aes_key`. Because iLink delivers one message
per context token, only the reply's first message, text or file, carries the
token; files after text go out unprompted. A 4xx from `getuploadurl` or the
CDN, or a refusal code, is final; other failures retry up to three times with
backoff, then again later with the same client ID.

### Feishu media

Message types map as follows. `image` (`image_key`), `file` (`file_key`,
`file_name`), `audio` (`file_key`, Opus), and `media` (video, `file_key`,
`file_name`) become files, although a voice message, which Feishu sends
without a transcript, gets the voice reply above rather than a download; a
`post` keeps its embedded `img` and `media`
elements as files and `emotion` elements as `[emoji]`. `sticker`,
`share_chat`, `share_user`, `location`, and `interactive` cards become text:
`[sticker]` (Feishu does not serve sticker files), `[shared a group chat]`,
`[shared a contact card]`, `[location: …]`, and `[card]` with the card's
titles and text, at most 2000 characters. `merge_forward` becomes
`[Forwarded messages]`; other types become `[<type> message]`, and `system`
messages are only marked seen. Files download through
`GET /open-apis/im/v1/messages/{message_id}/resources/{key}?type=image|file`
(`image` for images, `file` for the rest), from the message that holds them;
Feishu reports errors as JSON, sometimes with status 200, which SCV treats as
failures.

A reply to an earlier message (`parent_id`) fetches that message through
`GET /open-apis/im/v1/messages/{parent_id}` and shows it as
`[Quoting: <text>]` (or `an image`, `a file`, …), with its files. A forwarded
bundle fetches `GET /open-apis/im/v1/messages/{message_id}`, whose items after
the bundle itself name it in `upper_message_id`: up to 50 of them, and 16 KiB
of text, are listed under `[The forwarded messages:]`, with their files.

To send, SCV uploads with a `multipart/form-data` request: images of at most
10 MiB to `POST /open-apis/im/v1/images` (`image_type=message`), anything else,
larger images included, to `POST /open-apis/im/v1/files` with `file_type`
`pdf`, `doc`, `xls`, `ppt`, or `stream` and the file name. It then sends an
`image` or `file` message like a text part: a reply to the message, or a new
message to the owner's `open_id`, with the file's client ID as `uuid`. Checked
live on 2026-09-24, the scan-created app may read message resources and
upload images and files without any console change. An app that lacks a
scope gets code 99991672; SCV then logs that `im:resource` or `im:message` must
be added in the app's developer console and a version published, and the
model sees `[… download failed]`.

## Background reports

When the owner's session starts a background delegation (an `agent` call
with `background: true`; see [tools](tools.md#background-jobs)), the bridge
notes the job from the call's `tool.completed.jobs` (see
[protocol](protocol.md#tool-lifecycle-and-approval)) and keeps that
conversation's session open, without the 30-minute idle limit, until the model
has seen the job's result: in a report turn, or through a later
`agent_wait`, `agent_status`, or `agent_cancel` call. When the server
starts a turn reporting it (`turn.started` with an `origin`), the bridge
answers that turn's approval requests like the owner's own, collects its
answer, and sends it to the owner as an unprompted message (for Feishu, a
message to the owner's `open_id`): recorded as a pending delivery before
sending, retried with the same client ID, and moved to the held-reply store if
the platform refuses it outright. A report that finishes
during one of the owner's turns follows that turn's reply. Only direct chats
receive reports; group and non-owner sessions have no tools.

This is what lets the owner keep chatting while work runs: an owner session
starts with `auto_approve: true`, so the background agents it starts get the
approvals the owner's own turns get, and its system prompt tells the model to
hand real work to background agents and answer at once with the job handle
(see [Delegate first](tools.md#delegate-first)). The owner can ask how a job
is going or have it stopped at any time.

If an owner turn runs out of time while jobs are running, the bridge cancels
that turn (`turn.cancel`) and keeps the session, rather than replacing it,
which would cancel the jobs; the owner still gets the failure reply.

The bridge keeps reading a report turn that starts during one of the owner's
turns and finishes after it, so its answer goes out without waiting for the
owner's next message. It also records each running job in the account's
delivery state (`jobs`: the chat, job handle, delegating tool, the agent that
runs it, and the task the daemon named, the first line of the delegated
prompt) until the job is reported or its session closes. A job saved by SCV
0.3.0 names its agent only in the tool (`agent_codex`), and one saved now
still carries the tool, so either release reads the other's state.
A restart ends every session and so every job: on the account's next run, each
chat whose jobs were recorded gets one message listing the jobs that stopped,
each with its agent, such as `- job-1 (codex): Land the fix`.

## Restarts and notices

An owner can ask SCV from a chat to change, publish, and deploy SCV itself:
the delegated agent runs the feature flow, whose `publish.sh` first asks the
owner in that chat whether to publish (see
[Questions to the owner](#questions-to-the-owner)) and whose `deploy.sh` ends
with `scv restart --when-idle`. The daemon then restarts only once that agent has
finished (a nested SCV or ACP agent, which lives for its whole conversation:
once its turn has ended and, for a nested SCV, the background jobs of its own
session have been reported to it), its report is stored in the chat's outbox,
and no owner message is being answered, or after ten minutes at the latest (see
[architecture](architecture.md#planned-restarts)). Across the restart:

- Messages whose turns the restart interrupted are answered with "SCV
  restarted to update to vX before finishing this; ask again if you still need
  it." instead of the generic failure reply, on each account's first run after
  a planned restart only.
- Each chat is told which of its background jobs stopped, as above.
- When the watchdog has checked the new release, the daemon announces "SCV
  updated: now running vX (commit)." in the chat that asked, or that the
  update failed and was rolled back, or failed and was not rolled back. If that
  chat does not connect within two minutes, the announcement goes to the
  `[notify]` accounts, saying which chat asked.

On WeChat each of these messages, like the notices below, is a code block
labelled `system msg: ` (see [SCV's own messages](#scvs-own-messages)):

````text
```
system msg: SCV updated: now running v0.3.1 (abc1234).
```
````

Checked live with the owner on 2026-09-26: asked from WeChat, a delegated
agent installed 0.3.0 over a 0.2.1 daemon and scheduled the restart; the
0.2.1 watchdog saw 0.3.0 up with both accounts connected 20 seconds after
restarting the unit, and the announcement reached the WeChat chat that asked.

Notices nobody asked for (an update started from a terminal, a restart after
the daemon stopped unexpectedly, an enabled account disconnected for ten
minutes, which may mean its sign-in expired) go to the owner of the first
connected account in `[notify].owner`, or else to the chat the owner last
wrote from, and never through the account the notice is about. Each is queued
in that account's outbox like a background report, in SCV's `system msg: `
code block on WeChat. The daemon waits up to 30
seconds for the account to store a notice and otherwise counts it as not sent,
possibly trying another account; the account then drops it rather than sending
it late. See
[configuration](configuration.md#daemon-and-component-settings).

## Questions to the owner

Before a step that cannot be undone, a delegated agent (or anything else on
the host) can ask the owner yes or no with `scv confirm [--timeout SECS]
QUESTION`; the feature flow's `publish.sh` asks this way before publishing SCV
to crates.io. The question goes to the chat that started the work, found as
for a planned restart: the caller's `SCV_PARENT` chain names the delegation,
the delegation its daemon session, and the hub the direct chat that session
answers. Work that did not start in a chat, such as a TUI session, asks where
unprompted notices go: the owner of the first connected `[notify].owner`
account, or else the chat the owner last wrote from. Only an account owner's
direct chat can be asked; with none reachable, nothing is asked.

The daemon queues the question in that account's outbox like a notice, as one
unprompted message:

```text
<question>

Reply yes or no. No answer in <N> minutes counts as no.
```

On WeChat that message, and each reply below ("OK, going ahead.", "OK,
stopped.", "No answer, so stopped.", "The question was withdrawn, so
stopped."), is a code block labelled `system msg: `, with a longer fence
when the question holds three or more backticks in a row (see
[SCV's own messages](#scvs-own-messages)).

The question opens only once the platform has accepted that message. Until
then it waits in the outbox behind anything queued before it, such as a reply
that keeps failing, and nothing answers it. The pending delivery records the
question's ID (`question`, which releases before 0.3.0 ignore). A question the
platform refuses fails at once, and one still undelivered at its deadline fails
too, since the owner was never asked; either way the chat is told nothing
more. A question's message is never held for a later reply to carry, and one
that no longer waits (answered, run out, withdrawn, or dropped by a daemon
restart) is dropped from the outbox unsent. iLink can drop an unprompted
message silently (see [WeChat iLink contract](#wechat-ilink-contract)); such
a question looks delivered and simply goes unanswered.

Once the question is open, the owner's next direct message in that chat that
is an explicit answer, and that the platform says was sent no earlier than the
question's delivery, decides it. The platform's time is Feishu's message
`create_time` or iLink's `create_time_ms`, compared with this host's clock when
the platform accepted the question. A message written earlier, such as one
that Feishu's catch-up hands over after a reconnect or that a WeChat poll picks
up after its backoff, was meant for something else, and a message without a
platform time never answers either. After trimming, lowercasing, and dropping
trailing `.`, `!`, `。`, and `!`, the words `yes`, `y`, `是`, `是的`, `确认`,
and `同意` mean yes, and `no`, `n`, `否`, `不`, `不要`, `取消`, `算了`, and
`stop` mean no. Casual replies the owner may send about anything else, such as
`ok`, `好`, or `可以`, are not answers, and neither is a message with files. An
answer starts no turn: the bridge replies to it "OK, going ahead." or "OK,
stopped." once that reply is durable, and only then hands the answer to the
asker. Any other message runs as a normal turn while the question keeps
waiting. Other senders and group messages, the owner's own in a group
included, never answer. With no answer in time (default 30 minutes, at most 4
hours) the chat is told "No answer, so stopped."; an asker that stops
following the question for a minute (it was killed) has it withdrawn, and the
chat is told "The question was withdrawn, so stopped." if it had seen the
question.

A chat holds at most one question; asking again while one waits is refused.
Questions live only in the daemon's memory, in the channel hub next to the
notices, and a daemon restart drops them. `scv confirm` exits 0 for yes; 1 for
no or no answer in time; and 2 when nothing could be asked or the answer was
not learned: no daemon, a daemon too old for the command, no owner chat to
ask in, a question already waiting there, the question refused by the platform
or still undelivered at its deadline, or the daemon restarting while it
waited. A delegated agent may run it; it manages nothing.

Checked live with the owner on 2026-09-26: a question from a terminal went to
the first `[notify]` account (Feishu); `yes` made `scv confirm` exit 0 and
`不` exit 1, each answered within seconds with no turn. A question that an
agent delegated from WeChat asked came back to that WeChat chat, and `是`
answered it.

## Sessions and safety

Whose messages an account answers is a per-account setting, `senders`, that
only local CLI or daemon control (or an edit of `config.toml`) can change:

- `owner` (default): only the account's authenticated owner, the iLink
  `user_id` recorded at QR login or the Feishu `open_id` recorded at sign-in,
  is answered, in direct chats and, when a message mentions the bot, in
  groups. Anyone else's message is dropped silently: no reply or busy notice,
  no download, no daemon session, and no model turn. It is still marked seen
  and checkpointed like any handled message, so it is never replayed, and the
  log records only that the account ignored a message from someone other
  than its owner, never who sent it or what it said. An account with no known
  owner ID answers nobody; `scv channels status`, `scv config show`, and login
  say so.
- `anyone`: every sender who can reach the bot is answered, tool-free unless
  it is the owner holding remote tools (below).

Each direct-chat sender has one long-lived SCV protocol-v3 socket session. A
group message (a non-empty WeChat `group_id`, or a Feishu chat other than
`p2p`) uses a separate session per group and sender, so group members never see the sender's direct-chat history. Sessions
idle for 30 minutes after their last turn ends are dropped, and at most 32
sessions are live; a new conversation closes the least recently used idle one
that has no background jobs running or reports to send, and gets the busy
notice when every live conversation is busy that way.

Every session starts with `channel` set to the channel's name as its users know
it (`WeChat`, or `Feishu`/`Lark` by the account's brand), so the model knows it
is writing chat messages: short plain text, one message per turn, with no tool
output visible to the user (see [protocol](protocol.md#sessionstart)).

Polling continues while turns run. Each conversation runs its own messages in
order, one turn at a time, so a sender's later messages wait behind its current
turn while other senders are answered. At most four conversations run turns at
once; the rest wait for a slot, and a turn's time limit starts when its slot
does. A conversation may have 8 claimed messages and the account 64; a message
beyond either limit is answered at once with a busy notice asking the sender
to retry, without starting a turn. Each reply goes out with its own message's
context token. A turn the server fails keeps its session; a turn that times
out, or a session whose connection broke, resets only its own conversation's
session (a timed-out turn with background jobs running is cancelled instead;
see Background reports). Replies are delivered in the order turns complete, and a delivery
that keeps failing is retried with backoff without stopping polling or other
turns. Completed assistant output is
sent only after `turn.completed`; failures become short non-sensitive replies.
Network failures use bounded exponential backoff. Retained duplicate message
IDs and interrupted claims do not start another turn. Pending sends retain
their client IDs across recovery; remote exactly-once delivery is not promised.
Raw diagnostics, tool arguments, tool output, tokens, and host paths are not
forwarded as failure details to the chat.

Remote tool authority is a per-account setting, `remote_tools`, that only local
CLI or daemon control can change:

- `none` (default): every remote session uses `session.start` with
  `no_tools: true`, enforced by the server, and the bridge denies any approval
  request.
- `owner`: messages from the account's authenticated owner, the iLink
  `user_id` recorded at QR login or the Feishu `open_id` recorded at sign-in,
  start a full session with every configured SCV tool, and the bridge approves
  that session's approval requests. Other senders, and the owner writing in a
  group, keep `none` behavior. Accounts without a known owner ID grant tools to nobody.
  Owner turns may run for the configured tool ceiling
  (`tools.max_timeout_seconds`, default four hours) plus five minutes, so four
  hours and five minutes by default and at least 30 minutes, instead of 5; the
  ceiling is read from the workspace
  configuration when the component starts. Logout resets the setting
  to `none` before deleting credentials, so a later login never inherits it.

`scv channels run <channel> --remote-tools owner` reports whether the daemon
actually applied the grant; a login without an owner ID leaves it inactive with
a warning. `--senders` likewise reports what the daemon applied, and warns when
an owner-only account has no owner ID and so answers nobody. Logout resets
`senders` to `owner` along with the tool grant. Delegated calls to `claude` and `codex` need
their CLIs signed in for SCV first; see `scv agents login` in the
[tools reference](tools.md#signing-in-delegated-agents).

The bridge never invokes `scv exec --yes`.

Credentials are stored at `$SCV_HOME/credentials/<channel>/<account>.json`;
the checkpoint (WeChat's cursor, Feishu's per-chat times) and message IDs,
in-flight claims, pending replies, and held replies are stored at
`$SCV_HOME/state/channels/<channel>/<account>.json`. Claims and pending replies keep the
single-object form older bridges wrote while at most one of each exists, and
become lists when several do; a bridge older than `0.1.23` cannot read state
that holds several. Credentials, settings, and delivery
state use atomic writes and mode `0600` on Unix; the directories between the
SCV home and them are mode `0700`. Account names contain only ASCII letters, digits, `_`, and `-`.
Project configuration cannot select accounts, workspaces, or remote authority.

The shared bridge in `scv-channels` owns durable state, claims, sender
sessions, held replies, media fetching, storage, and retention, and delivery
retries; its `wechat` module owns iLink authentication, polling, message
parsing, the CDN's encryption, and sending; its `feishu` module owns Feishu
sign-in, the long connection, catch-up, message parsing, resources, uploads,
and sending. `scv-server::components` owns lifecycle and
health. Account selection uses `--account` (default `default`), not project
configuration. The [quality contract](quality.md) defines local-only verification.