Skip to Content
ДизайнChannelsRFC: "qwen tag" — персистентный мультиплеерный агент, резидент канала для qwen-code (в первую очередь для DingTalk)

RFC: “qwen tag” — персистентный мультиплеерный агент, резидент канала для qwen-code (в первую очередь для DingTalk)

Статус: Черновик (v2) Дата: 2026-06-25 Автор: (qwen-code)


Список изменений (v1 → v2)

В этой редакции закрыты все открытые решения (Open Decisions) из v1 (теперь это Принятые решения, §9) и исправлены семь дефектов корректности/согласованности, выявленных при ревью. Два ключевых изменения:

  • OD-1 больше не является блокером — это зафиксированная архитектура. Фаза 0 (Phase 0) поставляется на текущем пути AcpBridge; Фаза 1+ переносит хостинг каналов в демон qwen serve (через DaemonChannelBridge / раннер демонов каналов) для повторного использования пер-сессионной FIFO promptQueue, MultiClientPermissionMediator, eventBus, /workspace/memory и rate-limit. Каждый раздел, где ранее было написано “OD-1 открыт / блокирует всё”, теперь отражает принятое решение, и обязательство по использованию демона распространяется на §1, §4, §5, §6.1, §6.2, §6.3, §6.4 и §7.
  • Проактивный путь запуска (fire-path) переработан под реальный демон-путь. dispatchProactive из v1 был написан под семантику AcpBridge (очереди sessionQueues на стороне канала). При миграции на демон DaemonChannelBridge.prompt() выбрасывает исключение Prompt already in flight при наложении запросов (DaemonChannelBridge.ts:257-261) вместо постановки в очередь. В v2 проактивные промпты сериализуются через ChannelBase.sessionQueues для обоих вариантов, поэтому защита от выброса исключения никогда не срабатывает, а инвариант “никогда не отменяется” (never-cancellable) явно зафиксирован (§6.2).

Включенные решения и исправления:

  • OD-2 принято: один процесс на воркспейс/канал.
  • OD-3 принято: в Фазе 1 используется first-responder + единый clientId на уровне канала; в Фазе 2 — consensus/designated после появления реестра senderId→clientId и жизненного цикла; автоматический запрет (auto-deny) инструментов высокого риска на проактивных ходах.
  • OD-4 принято: в общей группе (тред) /clear требует явного confirm и ограничен списком config.allowedUsers, если он задан; /status только для чтения. (Команда /clear-channel с дефисом не парсится грамматикой слэш-команд; полноценный гейт владельца на каждого участника ожидает внедрения модели идентификации — OD-3/OD-11.)
  • OD-5 принято: исправить устаревший JSDoc в types.ts:42 на 'steer'; профиль группы тегов явно устанавливает dispatchMode: 'followup'.
  • OD-6 принято: префикс [senderName] на каждый ход (turn), без привязки к гейту instructedSessions; одно новое опциональное поле EnvelopealreadyPrefixed, чтобы синтетический повторный вход в режиме collect пропускал повторное добавление префикса. (Исправляет утверждение из v1 “нет новых полей envelope” — Fix #2.)
  • OD-7 решено на основе проверенных фактов о DingTalk API (§6.2/§6.5), элементы с низкой уверенностью по-прежнему помечены.
  • OD-8 принято: планировщик шлюза/демона является единственным владельцем cron; сессия тега не запускает свой внутри-сессионный Session cron; два хранилища cron находятся на непересекающихся путях, поэтому коллизия возможна только если оба планировщика запущены для одних и тех же задач.
  • OD-9 принято: агрегация “org” на каждый процесс + окна на каждый канал, побеждает самый строгий, фиксированное дневное окно; в v1 оценка токенов происходит на стороне канала, а путь использования демона читается после перехода на хостинг в демоне.
  • OD-10 принято: добавить скоуп channel (+channelKey) в writeContextFile.ts; channel-base получает запись/чтение через колбэк CLI-слоя, внедряемый через ChannelBaseOptions (без зависимости channel-base → core); глобальное пользовательское расположение ~/.qwen/channels/memory/.
  • OD-11 принято: senderName только для справки; clientId — единственный принципал безопасности; кольцо аудита в памяти + файл follow-up с добавлением в конец в ~/.qwen.
  • OD-12 принято: требовать --require-auth + токен для любого развертывания с демоном, не использующего loopback.

Исправления корректности сверх решений OD:

  • Fix #1 — конкурентность проактивного пути запуска переработана для демон-пути (§6.2), инвариант “никогда не отменяется” применяется как для варианта Фазы 0 с AcpBridge, так и для варианта Фазы 1+ с демоном.
  • Fix #2 — внутреннее противоречие удалено: §6.1/G2 больше не утверждает “нет новых полей envelope”; он признает наличие одного поля alreadyPrefixed.
  • Fix #3 — спроектирована привязка памяти (§6.3): точное изменение ChannelBaseOptions (колбэки readChannelMemory/writeChannelMemory) и то, кто их создает/внедряет в start.ts, при этом первичное чтение при загрузке сессии (bootstrap read) повторно использует гейт instructedSessions.
  • Fix #4 — спроектирован флаг возможности canColdSend (§6.2): где он объявлен, как его устанавливают DingTalk/Feishu и как планировщик выдает явную ошибку (fails loud).
  • Fix #5 — уточнение непересекающихся хранилищ для OD-8 (§6.2): хранилище шлюза и хранилище Session — это разные пути; единственный риск коллизии — если сессия тега также запускает внутри-сессионный cron, что закрыто гейтом OD-8.
  • Fix #6 — принудительное применение оценочного бюджета (§6.4): оценка может выдавать WARN/алерт, но никогда не должна жестко отклонять (hard-decline) промпт пользователя; ЖЕСТКОЕ отклонение только по реальным цифрам использования демона.
  • Fix #7 — атрибуция аудита в режиме followup (§6.4): передача senderId вместе с промптом в очереди, чтобы вызов инструмента/разрешение атрибутировались ходу, который фактически выполняется, а не последнему поставленному в очередь отправителю.

Проверенные факты из v1 (топология AcpBridge, авто-одобрение AcpBridge, абстрактный sendMessage, скоупы, дефолты парсера) сохранены без изменений.


1. Резюме

“qwen tag” — это единый общий агент qwen-code, который живет внутри чат-канала (в первую очередь группы DingTalk, во вторую — Feishu) и вызывается любым участником этого канала через @-упоминание. После вызова он запускает полный цикл агента qwen-code (инструменты, редактирование файлов, shell, MCP) для привязанного воркспейса, стримит свою работу обратно в канал по мере выполнения, запоминает канал между ходами и перезапусками и может действовать проактивно или по расписанию, не дожидаясь запроса. Это повторяет форм-фактор Claude Tag — единый персистентный мультиплеерный агент, который является резидентом комнаты, а не ботом для личных сообщений 1-на-1, — но он полностью построен на существующем стеке адаптеров каналов qwen-code (qwen channel start, packages/channels/*) и демоне qwen serve, а не на новом хостинговом сервисе.

Намеренный фокус этого RFC заключается в том, что реактивная половина форм-фактора по большей части уже поставлена, а проактивная/memory-половина — нет. Компоненты, которые делают сложным создание агента ответов в стиле Claude Tag, — долгоживущий процесс с мультиплексированием сессий, транспорт агента, сохраняющий инвариант “один промпт на сессию”, маршрутизация мультиплеерных сессий, контроль доступа на уровне канала, рендеринг стримящихся карточек и надежное сохранение сессий, — уже существуют и используются текущими адаптерами каналов. Не хватает четко ограниченного набора возможностей, которые превращают реактивного бота ответов в агента-резидента: атрибуция отправителя в общих сессиях, проактивный/планируемый путь вывода, память для каждой комнаты и мультиплеерное управление. Этот RFC определяет этот пробел в виде четырех областей разработки и специфицирует их для Фаз 0–2.

Примечание о “80%”: в ранних черновиках это формулировалось как “~80% поставлено”. Эта цифра непроверяема и преувеличивает ситуацию — весь проактивный движок (Область разработки 2) и память для каждой комнаты (Область разработки 3) совершенно новые, а для DingTalk в частности вообще нет пути инициации исходящих сообщений. Вместо этого мы формулируем это так: “реактивный путь построен; проактивный путь и пути памяти — нет”.

Факт топологии, ограничивающий весь RFC

Существует два различных способа подключения адаптера канала к агенту qwen, в двух разных процессах, и их смешивание — самая частая ошибка в предыдущих черновиках:

  • qwen channel start <name> (поставляемый путь). start.ts создает new AcpBridge(bridgeOpts) (start.ts:213,268,356,435), и AcpBridge.start() порождает дочерний процесс node <cliEntryPath> --acp (AcpBridge.ts:53-70), обмениваясь данными ACP по протоколу NDJSON через stdio. Этот дочерний процесс — автономный агент, а не HTTP-демон qwen serve. В этой топологии нет HTTP-демона, нет маршрута /workspace/memory, нет MultiClientPermissionMediator, нет кольца повтора eventBus и нет демонной promptQueue — всё это находится в packages/acp-bridge + packages/cli/src/serve, которые qwen channel start никогда не инстанцирует. Сериализация промптов здесь выполняется полностью на стороне канала через ChannelBase (мьютекс activePrompts в ChannelBase.ts:356-391 + цепочка sessionQueues в :394-470) и собственным инвариантом ACP дочернего процесса “один промпт на сессию”. AcpBridge.requestPermission автоматически одобряет каждый вызов инструмента (AcpBridge.ts:108-118).
  • qwen serve + DaemonChannelBridge (хостинг в демоне). DaemonChannelBridge (packages/channels/base/src/DaemonChannelBridge.ts) — это внутрипроцессный бридж, чья sessionFactory создает объекты Session демона. Этот путь запускает каналы внутри демона и, таким образом, наследует FIFO promptQueue из acp-bridge (bridge.ts:232,2855,3082), MultiClientPermissionMediator, eventBus и HTTP-маршруты. На сегодняшний день qwen channel start его не инстанцирует (ноль ссылок в start.ts). Острый угол, влияющий на проактивный дизайн: DaemonChannelBridge.prompt() не ставит в очередь — он выбрасывает исключение Prompt already in flight при наложении запросов (DaemonChannelBridge.ts:257-261); FIFO promptQueue, до которой он в итоге доходит, находится на стороне демона/acp-bridge, за этим внутрипроцессным защитным выбросом. Следовательно, проактивный движок должен сериализоваться на уровне канала (§6.2).

Зафиксированная архитектура (ранее OD-1, теперь принято): механизмы мультиклиентского демона переиспользуются путем переноса хостинга каналов в демон qwen serve начиная с Фазы 1.

  • Фаза 0 поставляется на текущем пути AcpBridge (инъекция идентификации не требует ни HTTP-маршрутов, ни медиатора).
  • Фаза 1+ запускает каналы под управлением демона qwen serve (через DaemonChannelBridge или раннер демонов каналов), поскольку проактивному движку, сохранению памяти для каждой комнаты и управлению требуются долговечность, маршруты, promptQueue, медиатор и шина событий демона.

Это больше не “открытый вопрос” и не “блокатор”: проводка Фазы 0 добавляет путь подключения DaemonChannelBridge (или флаг --daemon <url>), чтобы миграция была доступна в момент начала Фазы 1. Принадлежащий шлюзу планировщик (§6.2) построен нейтральным к миграции, чтобы он работал одинаково до и после переключения.

Что такое “qwen tag” конкретно

Развертывание “qwen tag” — это единый процесс агента, привязанный к одному воркспейсу, плюс адаптер qwen channel start dingtalk, настроенный так, что вся группа использует одну сессию агента. Должны совпадать две различные концепции скоупа:

  1. Скоуп маршрутизации канала (ChannelConfig.sessionScope, используется SessionRouter.routingKey()): определяет, как входящие сообщения сопоставляются с ключом маршрутизации. Для тега это должно быть 'thread', чтобы вся группа использовала один ключ маршрутизации (channel:(threadId||chatId), SessionRouter.ts:53). Дефолт парсера — 'user', а не 'thread' (config-utils.ts:91-92), поэтому в рецепте тега его нужно задавать явно.
  2. Скоуп сессии бриджа/ACP (sessionScope в DaemonChannelBridge / acp-bridge): определяет, как демон использует базовую ACP-сессию. DaemonChannelBridge.newSession() по умолчанию устанавливает 'thread' (DaemonChannelBridge.ts:229,240); внутрипроцессный путь acp-bridge по умолчанию использует 'single' (bridge.ts:709). Это отдельный регулятор, отличный от скоупа маршрутизации канала, и его нет на пути qwen channel start (AcpBridge.newSession(cwd) принимает только cwd, AcpBridge.ts:131).

При их наличии:

  • Один агент на комнату, вызывается по упоминанию. GroupGate обеспечивает соблюдение requireMention (по умолчанию true, GroupGate.ts:49), поэтому агент молчит, пока его не упомянут через @ или пока это не будет ответ боту (GroupGate.ts:51). Мультиплеерный ключ — sessionScope: 'thread', что маппится на channel:(threadId||chatId) (SessionRouter.ts:50-53), так что каждый участник использует один и тот же sessionId независимо от отправителя.
  • Реальная многоэтапная работа с инструментами. Входящие сообщения становятся промптами через ChannelBase.handleInbound(), который собирает promptText из текста сообщения, контекста цитаты ответа, путей к файлам вложений и (один раз на сессию) config.instructions (ChannelBase.ts:316-347), затем диспатчит через bridge.prompt(sessionId, promptText, { imageBase64, imageMimeType }) (ChannelBase.ts:425promptText это позиционный аргумент; объект опций содержит только поля изображений).
  • Стримит свою работу обратно в комнату. Адаптеры рендерят инкрементальный вывод в виде нативных карточек платформы (создание/обновление/финализация Feishu, markdown.ts; разбивка на чанки markdown в DingTalk, DingtalkAdapter.ts:144-169).
  • Запоминает канал. SessionRouter.persist() / restoreSessions() надежно сохраняют sessionId, цель и cwd и восстанавливают их через bridge.loadSession() при перезапусках (SessionRouter.ts:168-244); память воркспейса (QWEN.md / ~/.qwen/QWEN.md) читается/пишется через GET / POST /workspace/memory (workspace-memory.ts). Эта память имеет скоуп воркспейса/глобальный, а не для каждой комнаты — см. Область разработки 3.
  • Может действовать проактивно / по расписанию. Это та половина, которой еще не существует сквозным образом и которая является сердцем Фазы 1.

2. Мотивация

Инфраструктура, которая обычно требуется резидентному мультиплеерному ответному агенту, уже реализована в этом репозитории. Фактически не реализованной остается работа в четырех направлениях разработки.

Функциональность, необходимая для форм-фактора TagУже реализовано (ссылка)
Долгоживущий, многосессионный процессAcpBridge запускает долгоживущий дочерний процесс --acp (AcpBridge.ts:53-70); путь демона добавляет FIFO promptQueue для каждой сессии (bridge.ts:232,2855,3082)
Мультиплеерная маршрутизация “одна комната, одна сессия”SessionRouter, область действия 'thread' (SessionRouter.ts:53), переопределение для каждого канала setChannelScope() (SessionRouter.ts:40)
Семантика вызова по упоминаниюGroupGate, requireMention по умолчанию true (GroupGate.ts:49-52)
Контроль доступа + онбордингSenderGate, whitelist + флоу с кодом сопряжения; гейты применяются сначала для группы, затем для отправителя (ChannelBase.ts:240-252)
Сохранение маппинга сессий между перезапускамиПерсистентность SessionRouter (SessionRouter.ts:168-244)
Чтение/запись памяти рабочего пространстваGET / POST /workspace/memory (workspace-memory.ts); только области действия workspace и global; только для демона
Контроль прав нескольких участников + аудит (только для демона)MultiClientPermissionMediator, четыре политики, включая кворум consensus (permissionMediator.ts:621-637); отдельное кольцо аудита разрешений (permission-audit.ts)
Аутентификация, ограничение частоты запросов, безопасность loopback (только для демона)Глобальный bearer-токен (auth.ts:259-266) + многоуровневое ограничение частоты запросов для каждого clientId/IP (rate-limit.ts)
Примитив push-уведомлений в сессии (фоновые задачи)Очередь уведомлений Session + setNotificationCallback() направляет вывод фоновых задач/мониторов/оболочки в открытую сессию (Session.ts:688-689,2638-2668); isIdle() учитывает это (Session.ts:777)
Доставка на платформы (DingTalk + Feishu)Рабочие адаптеры с потоковыми карточками, медиа и реакциями (DingtalkAdapter.ts, FeishuAdapter.ts)

Поскольку Фаза 1+ выполняется под управлением демона (зафиксированная архитектура, §1), строки таблицы, помеченные как “только для демона”, становятся доступными возможностями для проактивного движка, сохранения памяти и управления — а не просто “целями на случай миграции”.

Четыре направления разработки, подробно описанные в §6:

  1. Конфигурация и идентификация для объявления тега (Фаза 0). Задокументированный рецепт конфигурации — sessionScope: 'thread', groupPolicy, requireMention, instructions, dispatchMode — плюс пробел с атрибуцией отправителя: handleInbound() намеренно не внедряет senderName в promptText (ChannelBase.ts:316-347; senderName используется только для контроля доступа в ChannelBase.ts:246). В общей сессии 'thread' агент не может понять, кто говорит. Фаза 0 внедряет маркер отправителя, точно так же, как уже внедряется контекст цитирования ответа (ChannelBase.ts:318).
  2. Проактивный движок / движок инициации исходящих сообщений (Фаза 1). Сегодня нет проактивного пути на границе канала: ChannelBase.sendMessage() является абстрактным (ChannelBase.ts:81) и вызывается только изнутри ответа. В DingTalk sendMessage() может отвечать только через кратковременный sessionWebhook, кэшируемый для каждого conversationId при входящем сообщении (DingtalkAdapter.ts:134-142), поэтому холодной группе вообще нельзя написать (DingtalkAdapter.ts:137-141 возвращает пустой результат). Фаза 1 добавляет резидентный для демона планировщик и проактивный путь отправки для DingTalk.
  3. Резидентная для канала память + извлечение (Фаза 2, часть памяти). Память рабочего пространства является глобальной для рабочего пространства, а не для каждой комнаты: POST /workspace/memory принимает только scope: 'workspace' | 'global' (workspace-memory.ts:118-125) и является строго аутентифицированным маршрутом мутации (deps.mutate({ strict: true }), workspace-memory.ts:114). Тегу, который “помнит этот канал”, нужно пространство имен памяти для каждой комнаты.
  4. Мультиплеерное управление + безопасность (Фаза 2, часть управления). Политики разрешений, подходящие для групп, ограничители для проактивных действий и детальный аудит, построенные на существующем механизме уровня clientId (а не уровня личности человека).

3. Цели и не-цели

Цели

  • G1 — Задокументировать и выпустить конфигурацию “тега” в DingTalk: готовый к копированию и вставке рецепт channels.dingtalk (явный sessionScope: 'thread', groupPolicy: 'allowlist' с перечисленным ID группы, requireMention: true, instructions и намеренно выбранный dispatchMode), дающий рабочий резидентный мультиплеерный агент, с повторным использованием parseChannelConfig() и существующих гейтов. В рецепте должно быть четко указано различие между областью действия маршрутизации и областью действия ACP, а также то, что значение по умолчанию парсера 'user' должно быть переопределено.
  • G2 — Атрибуция отправителя в общих сессиях. Внедрение маркера отправителя для каждого сообщения в promptText, чтобы агент мог различать говорящих в группе с областью действия 'thread', не нарушая внедрение instructions один раз на сессию, отслеживаемое с помощью instructedSessions (ChannelBase.ts:344-346). Маркер является посообщным (говорящий меняется каждый ход) и НЕ должен управляться через instructedSessions. Это требует одного нового опционального поля Envelope, alreadyPrefixed (types.ts), чтобы синтетический повторный вход в режиме collect не добавлял префикс дважды — см. §6.1. (В v1 это ошибочно описывалось как “только форматирование, без нового поля”.)
  • G3 — Проактивный движок. Механизм для (a) инициации вывода в канал, который только что не отправлял сообщений, и (b) срабатывания по расписанию независимо от любой открытой интерактивной сессии, с доставкой через существующий путь уведомлений для каждой сессии, где это возможно — включая проактивный API отправки DingTalk и сохраненное хранилище openConversationId с определенным владельцем обновления токена. Должен соблюдать инвариант ACP “один промпт на сессию” (NG6) путем сериализации через ChannelBase.sessionQueues (никогда не отменять ход человека через steer), при обеих топологиях.
  • G4 — Резидентная для канала память. Пространство имен памяти для каждой комнаты и путь извлечения, надстроенные над существующим механизмом /workspace/memory и механизмом instructions. Дизайн добавляет новую область действия channel (+channelKey) в writeContextFile.ts и достигает её из channel-base через CLI-колбэк, внедряемый через ChannelBaseOptions (без зависимости channel-base → core).
  • G5 — Мультиплеерное управление. Политики разрешений, подходящие для групп, ограничители для проактивных действий и аудит, построенные на MultiClientPermissionMediator и кольце аудита разрешений. Должен учитывать тот факт, что голоса атрибутируются clientId, а не личности человека, и что в одной общей сессии 'thread' каждый участник группы является одним и тем же клиентом демона.
  • G6 — Паритет Feishu для всего в G1–G5, рассматривается как последующий этап. Стабильный tenant_access_token Feishu уже поддерживает проактивные отправки в любой чат, имея только chatId (FeishuAdapter.ts:622-651), поэтому Feishu не нужен новый API отправки для G3 — только механизм пробуждения/планирования на уровне демона. Feishu объявляет canColdSend = true.
  • G7 — Повторное использование вместо изобретения заново. Каждая область разработки расширяет существующий механизм (гейты, роутер, бридж, медиатор, маршруты памяти, путь уведомлений в сессии, cron), а не вводит параллельную подсистему.

Не-цели

  • NG1 — Не размещенный, мультитенантный SaaS. “Тег qwen” — это один агентский процесс, привязанный к одному рабочему пространству (serve.ts:165-171; несколько рабочих пространств = один демон на каждое рабочее пространство на отдельных портах). Нет центральной плоскости управления.
  • NG2 — Никакой идентификации на уровне человека, биллинга или бюджетов затрат в этом RFC. Модель идентификации демона — это один глобальный bearer-токен (auth.ts:259-266) и атрибуция на уровне clientId во всей шине событий и аудите разрешений. Мы добавляем маркеры отправителя в промптах (G2), но не вводим аутентифицированные принципы на уровне пользователя, квоты на пользователя или отслеживание затрат. Маркеры отправителя — это рекомендательный текст промпта, а не граница аутентификации — каждый участник группы использует единые учетные данные рабочего пространства демона, и в общей сессии 'thread' является одним и тем же clientId демона.
  • NG3 — Шлюз с множественной идентификацией Фазы-3 выходит за рамки данного документа, упоминается только как ссылка на будущие этапы. Этот RFC охватывает Фазы 0–2.
  • NG4 — Feishu является вторичным, а не равнозначным основным. DingTalk — это эталонная реализация и источник всех разобранных примеров.
  • NG5 — Slack и другие западные платформы выходят за рамки. Зарегистрированные типы каналов: telegram, weixin, dingtalk, feishu и qq (channel-registry.ts:10-14); адаптер для Slack не существует.
  • NG6 — Не изменять инвариант ACP “один промпт на сессию”. Запланированный/проактивный промпт — это просто еще одна запись в sessionQueues канала; он не может выполняться параллельно с ходом пользователя в той же сессии и не может отменить его.
  • NG7 — Никакого нового движка для хранения памяти с областью действия чата. Резидентная для канала память (G4) надстраивает пространства имен над существующими файлами QWEN.md/AGENTS.md на основе файловой системы; без векторной БД или базы данных для каждой комнаты.

4. Оценка текущего состояния

Реализовано (B), частично (P), отсутствует (M). “Файл” ссылается на авторитетный символ. “Топология” указывает, существует ли возможность на пути канала AcpBridge (A), на пути демона qwen serve (D) или на обоих — и, поскольку Фаза 1+ обязана выполняться под управлением демона, пометка “→D” указывает, где именно миграция открывает данную возможность.

Функциональностьqwen-code сегодня (файл / символ)ТопологияПробелРазмер
Маршрутизация “одна комната, одна сессия”SessionRouter.routingKey() 'thread' (SessionRouter.ts:44-60)A+DОбласть действия по умолчанию — 'user' (config-utils.ts:91-92); оператор должен установить 'thread'Конфиг (S)
Вызов по упоминаниюGroupGate.requireMention по умолчанию true (GroupGate.ts:49-52)A+DНет — уже корректно
Контроль доступа / онбордингSenderGate, whitelist + сопряжение (ChannelBase.ts:240-252)A+DНет
Сохранение маппинга сессийSessionRouter.persist/restoreSessions (SessionRouter.ts:168-244)A+DНет
Атрибуция отправителя в промптеhandleInbound() собирает promptText без senderName (ChannelBase.ts:316-347)A+DsenderName никогда не внедряется; агент не может понять, кто говорил; требуется новый Envelope.alreadyPrefixedКод (S)
Сериализация промптовChannelBase.sessionQueues/activePrompts (:356-470); демон promptQueue (bridge.ts:2855)A (канал) / D (демон)DaemonChannelBridge.prompt() ВЫБРАСЫВАЕТ исключение при перекрытии (:257-261) — проактивный движок должен сериализовать на стороне канала; dispatchMode по умолчанию 'steer' отменяет параллельные (:354,371-379)Конфиг + Код (S)
Инициация исходящих / проактивная отправкаChannelBase.sendMessage() абстрактный (:81); DingTalk только webhook (DingtalkAdapter.ts:134-142)A+DНет проактивного шва; холодной группе DingTalk нельзя написать; требуется флаг возможности canColdSendКод (L)
Планировщик уровня демонаCron привязан к сессии (Session.ts:667-668), умирает при dispose() (:790-812)A+D (шлюз) → D (аудит/переиспользование очереди)Нет эндпоинта планировщика демона в serve/ или channels/; планировщик шлюза является единственным владельцем (OD-8)Код (L)
Примитив push-уведомлений в сессииsetNotificationCallback (Session.ts:2638-2668)A+DДоставляет только в живую сессию; не может разбудить утилизированную(переиспользование)
Память для каждой комнаты/workspace/memory, области действия workspace|global (workspace-memory.ts:118-125)Только DНет области действия чата/канала; новая область действия channel + CLI-колбэк (без зависимости от core)Код (M)
Голосование за разрешения для нескольких участниковMultiClientPermissionMediator, 4 политики (permissionMediator.ts:621-637)D (унаследовано от Фазы 1+)AcpBridge автоматически одобряет (AcpBridge.ts:108-118); голоса привязаны к clientId, один клиент на каналКод (L)
Журнал аудитаPermissionAuditRing FIFO 512 (permission-audit.ts)D + кольцо на стороне каналаНет человеческого senderId; в памяти, теряется при перезапуске; дополнение с append-only в ~/.qwenКод (M)
Бюджет токенов / затратнет (ограничение частоты только по количеству запросов, rate-limit.ts)реестр на стороне канала + использование DНет счетчика расходов; оценки v1 (рекомендательные), реальный дебет только при размещении на демонеКод (M)
Область действия инструментов/MCP для каждого каналаcoreTools/allowedTools/excludeTools (config.ts:727-729); фильтр разрешений MCP (:3327-3333)для каждого ConfigНет пути аргументов spawn от канала к дочернему процессу --acp (AcpBridge); Config на уровне демона после размещенияКод (M)
Проактивная отправка DingTalkне реализовано (только robot/emotion, messageFiles/download)A+DНовый эндпоинт + сохраненный openConversationId + обновление токена (проверенный контракт, §6.2)Код (L)
Проактивная отправка FeishusendMessage() через tenant_access_token (FeishuAdapter.ts:622-676)A+DНет — canColdSend = true
Ключ размеров: S = конфигурация/небольшой код, M = модуль + изменение интерфейса, L = изменение в нескольких пакетах или новая подсистема.

5. Архитектура

qwen tag — это не новый runtime. Это четыре тонких слоя, добавленных поверх существующего стека адаптеров. Базовый слой уже обеспечивает работу агента с поддержкой многопользовательского режима, выполнения инструментов и MCP, доступного через чат-канал. Четыре новых слоя закрывают следующие пробелы 1:1: (1) кто говорит — идентификатор отправителя никогда не попадает в промпт; (2) действие без запроса — нет пути для исходящей инициации, внутри-сессионный cron умирает вместе с сессией; (3) память канала — память глобальна для рабочего пространства; (4) управление общим мозгом — авторизация использует один глобальный токен, без бюджета на канал.

Каждый слой ниже указывает, какую топологию он предполагает (см. §1). Принятое разделение: Фаза 0 на AcpBridge; Фаза 1+ на демоне qwen serve через DaemonChannelBridge.

Базовый слой (существующий) — топология qwen channel start (Фаза 0)

один хост, одно рабочее пространство ┌──────────────────────────────────────────────────────────────────────────────┐ │ qwen channel start dingtalk │ │ │ │ ┌────────────────────┐ Envelope ┌───────────────────────────────────┐ │ │ │ DingtalkAdapter │ ──────────────▶ │ ChannelBase.handleInbound() │ │ │ │ (stream client, │ │ 1 GroupGate.check (упоминание/ │ │ │ │ webhooks map by │ ◀────────────── │ политика/белый список) │ │ │ │ conversationId) │ text/markdown │ 2 SenderGate.check (сопряжение) │ │ │ │ sendMessage() │ │ 3 команды slash / "!" │ │ │ └────────────────────┘ │ 4 router.resolve(...) │ │ │ ▲ sessionWebhook (истекает, │ 5 dispatchMode (управление по │ │ │ │ только для входящих сообщ.) └───────────────┬───────────────────┘ │ │ │ │ sessionId │ │ │ ┌────────────────▼──────────────────┐ │ │ │ │ SessionRouter │ │ │ │ │ routingKey(): user|thread|single │ │ │ │ │ persist() → JSON (восст. после сбоя)│ │ │ └────────────────┬──────────────────┘ │ │ │ события textChunk / toolCall ┌────────────────▼──────────────────┐ │ │ └─────────────────────────────── │ AcpBridge (НЕ HTTP-демон) │ │ │ │ запускает дочерний `node <cli> --acp`│ │ │ ClientSideConnection через stdio │ │ │ │ requestPermission АВТО-ОДОБРЕНИЕ │ │ │ └────────────────┬──────────────────┘ │ └──────────────────────────────────────────────────────────┼─────────────────────┘ │ ACP / NDJSON (stdio) ┌──────────────────▼─────────────────────┐ │ дочерний процесс агента (`--acp`) │ │ один промпт в полете на ACP сессию │ │ внутри-сессионный cron (Session.ts) — │ │ ОТКЛЮЧЕН для tag сессий (OD-8); MCP, │ │ инструменты. │ │ НЕТ promptQueue/eventBus/mediator │ └─────────────────────────────────────────┘

Топология с хостингом на демоне (Фаза 1+) — qwen serve + DaemonChannelBridge

один хост, одно рабочее пространство, ОДИН демон ┌──────────────────────────────────────────────────────────────────────────────┐ │ qwen channel start dingtalk (каналы размещены ВНУТРИ демона) │ │ ┌────────────────────┐ Envelope ┌────────────────────────────────────────┐│ │ │ DingtalkAdapter │ ──────────▶ │ ChannelBase.handleInbound() ││ │ │ pushProactive() │ ◀────────── │ gates → governor.admit → router ││ │ │ canColdSend = false*│ │ → sessionQueues (FIFO, сериализация) ││ │ └────────────────────┘ └───────────────┬────────────────────────┘│ │ ▲ проактивная групповая отправка │ bridge.prompt() ││ │ │ (openConversationId) ┌───────────────▼────────────────────────┐│ │ ┌──────┴────────────┐ │ DaemonChannelBridge ││ │ │ ChannelCronSched │──fire────────▶│ prompt() ВЫБРАСЫВАЕТ исключение при ││ │ │ (принадлежит │ dispatchProa- │ перекрытии (:257-261) ││ │ │ шлюзу, единственный│ ctive через │ → поэтому все промпты ДОЛЖНЫ поступать ││ │ │ владелец cron) │ sessionQueues │ сериализованно через sessionQueues ││ │ └────────────────────┘ └───────────────┬────────────────────────┘│ │ │ in-process Session │ │ ┌────────────────▼────────────────────────┐│ │ │ демон: acp-bridge FIFO promptQueue, ││ │ │ MultiClientPermissionMediator, eventBus, ││ │ │ маршруты /workspace/memory + /channel, ││ │ │ rate-limit, bearer auth ││ │ └──────────────────────────────────────────┘│ └──────────────────────────────────────────────────────────────────────────────┘ * canColdSend в DingTalk становится true после выпуска пути проактивной отправки (§6.2).

Ключевые инварианты, на которые мы опираемся (проверено):

  • Thread scope — ключ к многопользовательскому режиму. routingKey() возвращает ${channelName}:${threadId || chatId} в режиме 'thread' (SessionRouter.ts:53); resolve() переиспользует этот ключ (:79-83). Scope по умолчанию — 'user' (:25); qwen channel start устанавливает scope для каждого канала через router.setChannelScope(name, config.sessionScope) (start.ts:361-362) в многоканальном пути, или через конструктор ChannelBase из config.sessionScope (ChannelBase.ts:62-64) в одноканальном пути. Для многопользовательского режима оператор должен установить sessionScope: "thread".
  • Сериализация промптов. В AcpBridge newSession(cwd) принимает только cwd (AcpBridge.ts:131), а AcpBridge.prompt() не имеет защиты от параллелизма — сериализация обеспечивается dispatchMode в ChannelBase: collect буферизует (:361-370,445-463), steer отменяет промпт в процессе выполнения (:371-379), followup выстраивает в цепочку через sessionQueues (:381-383,394-470). Значение по умолчанию в runtime — 'steer' (:354); JSDoc в types.ts:42 указывает 'collect'устарело; v2 исправляет это на 'steer' (OD-5). В пути демона DaemonChannelBridge.prompt() выбрасывает исключение при перекрытии (:257-261); демон FIFO promptQueue (bridge.ts:2855,3082) находится за этой защитой от исключений. Следствие (критично для §6.2): все промпты — как от пользователей, так и проактивные — должны поступать в bridge.prompt() уже сериализованными через ChannelBase.sessionQueues.
  • sendMessage абстрактен. ChannelBase.sendMessage() является abstract (:81); DingtalkAdapter.sendMessage() (:134-170) отправляет сообщения через sessionWebhook для каждого conversationId, который кэшируется только при входящих сообщениях (:516-517) и имеет срок действия — для “холодной” группы нет кэшированного webhook, и вызов молча возвращает управление (:137-141).
  • Инварианты демона, наследуемые в Фазе 1+. MultiClientPermissionMediator (permissionMediator.ts:621-637), кольцо повтора eventBus (eventBus.ts:92), FIFO promptQueue для каждого SessionEntry (bridge.ts:2855-3082) становятся доступны после размещения каналов под управлением qwen serve (зафиксировано, §1).

Четыре новых слоя

┌───────────── управление (Слой 4) ─────────────┐ │ шлюз бюджета ходов/затрат для каждого канала │ │ белый список проактивных действий, тихие часы,│ │ аварийный выключатель │ └───────────────────────┬─────────────────────────┘ │ оборачивает все входящие + исходящие inbound ┌──────────────────────────▼─────────────────────────┐ outbound ───────▶ │ внедрение идентификатора (Слой 1) │ ────────▶ │ добавление к promptText контекста спикера + канала │ └──────────────────────────┬─────────────────────────┘ ┌──────────────────────────▼─────────────────────────┐ │ память канала (Слой 3) │ │ фрагмент для каждого канала, внедряется при │ │ старте сессии; сохраняется через callback │ │ на уровне CLI (базовый помощник) │ └──────────────────────────┬─────────────────────────┘ ┌──────────────────────────▼─────────────────────────┐ │ проактивный движок (Слой 2) │ │ планировщик шлюза → sessionQueues → bridge.prompt →│ │ channel.pushProactive() с фоллбэком для хол. групп │ └─────────────────────────────────────────────────────┘

Слой 1 — Внедрение идентификатора. Топология: обе; демон не требуется. handleInbound() никогда не помещает senderName в promptText (ChannelBase.ts:246 читает его только для SenderGate.check(); Envelope.senderName существует в types.ts:69). Дизайн: одна точка внедрения, управляемая конфигурацией, в handleInbound(), после префикса referencedText (:316-319), активируемая при envelope.isGroup, плюс новый флаг Envelope.alreadyPrefixed для повторного входа в collect. Подробно описано в §6.1.

Слой 2 — Проактивный движок. Топология: планировщик, принадлежащий шлюзу, нейтральный к миграции; работает под демоном в Фазе 1+. Внутри-сессионный cron умирает при dispose() (Session.ts:790-803); конечной точки планировщика демона не существует. DingtalkAdapter.sendMessage() не может достичь “холодной” группы (:137-141). Дизайн: планировщик, резидентный для шлюза, который инициирует срабатывание через ChannelBase.sessionQueues (никогда не steer) и направляет завершение в channel.pushProactive(). Подробно описано в §6.2.

Слой 3 — Память канала. Топология: путь сохранения через callback на уровне CLI; внедрение на стороне канала. Память является глобальной только для рабочего пространства (workspace-memory.ts:86-303). Дизайн: фрагмент памяти для каждого канала, внедряемый при старте сессии (переиспользование шлюза instructions, срабатывающего раз за сессию), плюс новый scope channel на пути записи, доступный из channel-base через внедренные callbacks (без зависимости channel-base → core). Подробно описано в §6.3.

Слой 4 — Управление. Топология: обертка-шлюз на стороне канала; rate-limiter на стороне демона в Фазе 1+. Демон имеет один глобальный bearer токен (auth.ts:259-266), ограничение скорости для каждого clientId/IP и не имеет бюджета на канал. Дизайн: ChannelGovernor/BudgetLedger, оборачивающие handleInbound() и планировщик. Подробно описано в §6.4.

Поток данных 1 — входящий @qwen в групповом треде

Этот поток идентичен по форме в обеих топологиях; единственное отличие заключается в том, где находятся сериализация и разрешения. В AcpBridge (Фаза 0) сериализация обеспечивается ChannelBase.sessionQueues, а разрешения автоматически одобряются дочерним процессом; в демоне (Фаза 1+) сериализация по-прежнему осуществляется через ChannelBase.sessionQueues (защита от исключений демона никогда не срабатывает, потому что слой канала уже выполнил сериализацию), а разрешения проходят через MultiClientPermissionMediator.

  1. DingTalk → адаптер. Участник отправляет “@qwen summarize today’s incidents”. Stream-клиент доставляет DingTalkMessageData с conversationId, sessionWebhook, отправителем и isInAtList. DingtalkAdapter кэширует webhooks.set(conversationId, sessionWebhook) (:516-517) и генерирует Envelope с isGroup:true, isMentioned:true, chatId = conversationId.
  2. Governor (Слой 4). ChannelGovernor/BudgetLedger.admit() проверяет бюджет ходов/затрат канала (рекомендательный режим, пока не появятся реальные данные об использовании, §6.4) и аварийный выключатель. Жесткое отключение / явный лимит с реальными числами → отклонить и ответить; превышение порога только по оценке → WARN, никогда не жесткое отклонение (Fix #6).
  3. Шлюзы. GroupGate.check() проходит (упоминание удовлетворяет значению по умолчанию requireMention:true); SenderGate.check() проходит (:246).
  4. Маршрутизация. router.resolve(...) вычисляет dingtalk:<conversationId> в scope 'thread' (требуется sessionScope:"thread"), возвращает общий sessionId группы. persist() записывает его.
  5. Память (Слой 3) + идентификатор (Слой 1). На первом ходу память канала + config.instructions добавляются в начало один раз (instructedSessions, :344-347). Внедрение идентификатора добавляет [Alice] в начало каждого сообщения.
  6. Фиксация авторства. Разрешенные senderId/senderName записываются в элемент очереди, переносимый в sessionQueues (Fix #7), а не объединяются позже по временной метке.
  7. Диспетчеризация. Профиль tag устанавливает followup (никогда steer); параллельное сообщение Боба выстраивается в цепочку через sessionQueues (:394-470).
  8. Bridge. bridge.prompt(sessionId, promptText, {imageBase64, imageMimeType}) перенаправляет через stdio ACP (AcpBridge.prompt, AcpBridge.ts:147) или в сессию демона (DaemonChannelBridge.prompt) — достигается только тогда, когда предыдущий ход исчерпал activePrompts, поэтому защита от исключений демона (:257-261) никогда не срабатывает.
  9. Обратный поток. textChunkonChunk (:416-422); onResponseComplete → DingtalkAdapter.sendMessage() использует кэшированный sessionWebhook (“теплая” группа).

Поток данных 2 — запланированная проактивная отправка в «холодную» группу

  1. Срабатывает планировщик. Размещенный в шлюзе ChannelCronScheduler пробуждается в 09:00 для daily-standup → dingtalk:<convA>. Это не внутри-сессионный cron (он отключен для tag-сессий, OD-8/§6.2; и все равно умирает, как только сессия утилизируется — dispose() очищает cronQueue, Session.ts:790-803).
  2. Governor (L4). Проверяет проактивный allowlist и тихие часы (явный источник часового пояса). Вне окна / нет в allowlist → пропуск + лог. Планировщик проверяет adapter.canColdSend перед попыткой доставки; если false, он генерирует явную ошибку (логирует + записывает lastError), никогда не завершается молча (Fix #4).
  3. Синтетический envelope. senderId:'__cron__', chatId: convA, isGroup:true, isMentioned:true, без messageId. Синтетический промпт несет собственную атрибуцию (createdBy) в элементе очереди.
  4. Сериализация, без вытеснения. dispatchProactive выстраивается в цепочку ChannelBase.sessionQueues и ожидает завершения любого выполняющегося человеческого хода (activePrompts.get(sessionId)?.done). Он никогда не вызывает steer/cancelSession и никогда не вызывает bridge.prompt(), пока удерживается activePrompts — поэтому daemon-исключение Prompt already in flight (:257-261) не может сработать (§6.2, Fix #1).
  5. Отправка в «холодную» группу. pushProactive(convA, text) обнаруживает, что webhooks.get(convA) равен undefined, и переключается на новый проактивный путь: сохраненный openConversationId, свежий токен app-credentials, POST https://api.dingtalk.com/v1.0/robot/groupMessages/send с robotCode = config.clientId, msgKey:'sampleMarkdown', msgParam (JSON строка). (В Feishu шаг 5 — это существующий sendMessage() через tenant_access_token; canColdSend = true.)
  6. Бюджет + аудит. Проактивный ход потребляет бакет бюджета канала (рекомендательное списание, пока не станет доступно использование на хосте daemon); записывается с createdBy в качестве исходной идентичности и originatorClientId на транспортном уровне (человеческая идентичность не выдумывается, eventBus.ts:60).

Почему такая форма (переиспользование вместо изобретения)

Каждый новый слой подключается к существующей точке расширения: идентичность — в месте сборки promptText, проактивность — в sessionQueues + pushProactive(), память — в механизме instructions/writeContextFile, управление — как обертка над цепочкой gate. Единственное структурное требование — переиспользование механизмов daemon слоями 2–4 — удовлетворяется за счет зафиксированной миграции на daemon (§1): Фаза 0 поставляется на AcpBridge; Фаза 1+ работает под qwen serve.


6. Детальный дизайн

6.1 Многопользовательский режим и Identity (Build Area 1)

«Тег qwen» живет в групповом чате. Каждый участник общается с одним и тем же агентом, который должен (a) поддерживать один общий разговор для всего канала, (b) знать, кто говорит в каждом ходе, (c) не позволять сообщению одного участника уничтожить выполняющуюся задачу другого, и (d) в идеале запрашивать у группы одобрение на рискованные вызовы инструментов. Сегодня в qwen-code есть примитивы для (a)–(c); (d) — это работа для Фазы 1+ на хосте daemon (зафиксированная миграция, §1).

Общая для группы сессия: sessionScope: 'thread'

В режиме 'thread' senderId исключается из ключа маршрутизации, поэтому каждый участник резолвится в один sessionId (SessionRouter.ts:53,72-92) — это делает агента общей, резидентной для канала сущностью, а не N приватными ботами.

  • Scope для каждого канала, а не глобальный переключатель. По умолчанию роутер использует 'user' (:25), и в конфиге канала по умолчанию тоже 'user' (config-utils.ts:91-92). Личные сообщения (DM) и однопользовательские каналы остаются в режиме 'user'. Профиль тега устанавливает sessionScope: 'thread' в settings.json, что применяется для каждого канала через setChannelScope() (мульти-канал, start.ts:361-362) или конструктор ChannelBase (один канал, ChannelBase.ts:62-64).
  • Стабильность threadId/chatId в DingTalk. Адаптер DingTalk никогда не устанавливает Envelope.threadId (DingtalkAdapter.ts:541-551), поэтому routingKey() использует фоллбэк threadId || chatId до chatId, схлопывая группу в одну сессию на каждый chatId (как и требуется). Важно: chatId = conversationId || sessionWebhook (:534). Для реальных групповых сообщений conversationId присутствует и стабилен; если сообщение когда-либо придет без него, chatId откатится к истекающему URL sessionWebhook, и ключ треда дестабилизируется. Профиль обрабатывает отсутствующий conversationId как жесткую ошибку (отбрасывает сообщение), а не молча использует webhook в качестве ключа.

Персистентность покрывает восстановление после сбоев (SessionRouter.ts:168-244): перезапуск daemon повторно подключает группу к той же общей сессии через bridge.loadSession().

Новая опасность: /clear и /status с thread-scope действуют на весь канал

Общий обработчик /clear вызывает router.removeSession(this.name, senderId, chatId) (ChannelBase.ts:147-152), а /status вызывает router.hasSession(...) (:203-208); оба маршрутизируются через routingKey(), который игнорирует senderId в режиме 'thread'. Таким образом, /clear от любого одного участника стирает общую сессию для всего канала и сбрасывает instructedSessions — это «грабли», которые сбрасывают всё для всех одним нажатием.

Решение (OD-4): в общей (thread) группе /clear (и его алиасы) требует явного токена confirm и ограничен config.allowedUsers, если этот список задан; в противном случае очистка происходит напрямую (в личных сообщениях и группах для одного пользователя затрагивается только собственная сессия вызывающего, поэтому gate не нужен). Команда сохраняет имя /clear, потому что слэш-парсер принимает только [a-zA-Z0-9_] (команда с дефисом /clear-channel распарсится как clear + аргумент -channel); явный confirm служит индикатором деструктивного действия. Настоящий owner-gate для каждого участника (различающий админов и участников независимо от allowlist чата) ожидает внедрения модели идентичности (OD-3/OD-11). /status остается read-only для общей сессии.

Пробел с атрибуцией отправителя и его исправление

handleInbound() собирает promptText из envelope.text, префикса цитаты referencedText, путей вложений и config.instructions (один раз на сессию) (ChannelBase.ts:315-347); envelope.senderName читается только для SenderGate.check() (:246). В группе с режимом 'thread' агент видит недифференцированный поток.

Исправление (OD-6) — префикс [senderName] для групповых ходов, в самом начале построения промпта (:315-316), на каждом ходу:

let promptText = envelope.text; // Multiplayer attribution: in a thread-shared session, tag each turn with the // speaker. Skip 1:1 sessions (sender is invariant). Must fire EVERY turn — // not gated by instructedSessions (the speaker changes each message). The // alreadyPrefixed flag lets collect-mode synthetic re-entry skip this step. if (envelope.isGroup && !envelope.alreadyPrefixed) { const who = envelope.senderName || envelope.senderId || 'unknown'; promptText = `[${who}] ${promptText}`; } if (envelope.referencedText) { promptText = `[Replying to: "${envelope.referencedText}"]\n\n${promptText}`; }
  • Gate по envelope.isGroup (types.ts:75), а не по scope.
  • Префикс перед referencedText, чтобы порядок читался как [Alice] [Replying to: "..."] <text>.
  • Используем senderName, а не senderId. В DingTalk senderName = data.senderNick || 'Unknown' (DingtalkAdapter.ts:544), никогда не пустое; цепочка senderId → 'unknown' — это защитная мера.
  • Опасность двойного префикса в режиме collect, решенная добавлением одного нового поля. Объединенный повторный вход строит syntheticEnvelope, чей text представляет собой уже префиксованную объединенную строку, и повторно входит в handleInbound() (:449-462), что добавило бы префикс снова. v2 добавляет одно новое опциональное поле Envelope, alreadyPrefixed?: boolean (types.ts); синтетический envelope для collect устанавливает его в true, и шаг добавления префикса, описанный выше, пропускается, если оно установлено. (Это исправляет утверждение v1 о том, что изменение «касается только формата, без новых полей envelope» — Fix #2. Это единственное новое поле envelope, которое вводит данный RFC; протокол bridge/ACP не изменен.)

Групповой dispatchMode по умолчанию: steerfollowup

steer (по умолчанию в рантайме, :354) отменяет выполняющийся промпт через bridge.cancelSession() (:371-379). В общей группе, если Боб отправит что-либо, пока агент работает над запросом Алисы, steer отменит задачу Алисы — случайный отказ в обслуживании. Профиль тега устанавливает dispatchMode: 'followup', чтобы сообщение Боба встало в очередь за задачей Алисы (sessionQueues FIFO, :381-383,394-470). Устанавливайте это в профиле группы (groups["*"].dispatchMode = "followup"), а не меняя глобальное значение по умолчанию — в личных сообщениях сохраняется UX самопрерывания через steer. Изменения кода не требуются, кроме задокументированного значения по умолчанию в профиле; v2 исправляет устаревший JSDoc в types.ts:42 на 'steer', чтобы код и комментарий совпадали (OD-5). collect приемлем для групп с очень высоким трафиком (ограничивает глубину очереди) ценой размытия атрибуции.

Поскольку профиль тега для групп всегда followup (никогда steer), проактивный движок наследует чистый инвариант: нет гонки между steer и проактивностью, потому что ни один путь в группе с тегом не отменяет выполняющийся промпт. Этот инвариант переформулирован и обеспечивается в §6.2.

Handoff — «продолжить с того места, где остановился предыдущий участник»

С 'thread' + префиксами [senderName] + followup handoff является поведением по умолчанию: сессия хранит полную историю с несколькими спикерами. Два эргономичных дополнения: read-only команда /who (через protected registerCommand(name, handler), :141-143 — не приватную карту commands), сообщающая об активном sessionId/cwd/резюме задачи; и идемпотентное повторное подключение при перезапуске (уже покрывается restoreSessions()).

Одобрения несколькими участниками — фазирование (OD-3, решено)

Намерение верное: рискованные вызовы инструментов должны иметь возможность группового одобрения, и qwen-code поставляется с MultiClientPermissionMediator с четырьмя политиками (permissionMediator.ts:348,621-637). Но ни к одному из них нельзя обратиться из канала на пути Phase-0 AcpBridge:

  1. qwen channel start подключает AcpBridge, чей requestPermission автоматически одобряет каждый запрос (AcpBridge.ts:108-118). Никакого промпта для одобрения.
  2. Медиатор живет в HTTP-слое serve daemon. Единственный способный обрабатывать разрешения channel bridge — это DaemonChannelBridge (respondToPermission, :346-374) — он становится доступен, когда Фаза 1 мигрирует хостинг канала в daemon (зафиксировано, §1).
  3. config.approvalMode — это мертвое поле — оно парсится (config-utils.ts:94) и типизируется (types.ts:36), но не читается ни одним адаптером или bridge.

Принятое фазирование:

  • Фаза 0: никаких групповых одобрений. Контролируем риски с помощью sender allowlist + requireMention + консервативного набора инструментов агента. Не утверждаем, что approvalMode что-то делает.
  • Фаза 1: канал работает на пути daemon-bridge (зафиксированная миграция); выводим permission_request в виде карточки DingTalk; поставляем first-responder с одним clientId на уровне канала (нажатие любого разрешенного участника разрешает запрос; атрибуция на уровне гранулярности канала). Не требует карты senderId → clientId. Автоматически отклоняем инструменты с высоким риском на проактивных ходах (ход, инициированный __cron__, не может ответить на промпт разрешения).
  • Фаза 2: добавляем consensus/designated для каждого участника, как только появятся маппинг senderId → clientId и жизненный цикл clientId (утилизация, границы refcount). Примечание: один синтетический clientId на каждый senderId бесконечно растит карту refcount clientIds и должен утилизироваться.

Сводка конкретных изменений (Build Area 1)

ИзменениеГдеТип
Профиль группы устанавливает sessionScope: 'thread'settings.json + setChannelScope (start.ts:359-363)Конфиг
Обработка отсутствующего DingTalk conversationId как ошибкиDingtalkAdapter.ts ~:534Код (S)
Префикс [senderName] для групповых ходовChannelBase.handleInbound ~:316Код (S)
Новое опциональное поле Envelope.alreadyPrefixedtypes.ts (Envelope)Код (S)
Установка alreadyPrefixed при синтетическом повторном входе collectChannelBase.ts:449-462Код (S)
/clear confirm + allowlist gate в общих группах; /status read-onlyобщие команды (:147-217)Код (S)
Профиль группы устанавливает dispatchMode: 'followup'groups["*"] в settings.jsonКонфиг
Исправление устаревшего JSDoc dispatchMode'steer'types.ts:42Исправление комментария
Команда handoff /whoregisterCommand (:141)Код (S)
Миграция на daemon-bridge заменяет авто-одобрение AcpBridgeхостинг DaemonChannelBridge (зафиксировано)Фаза 1 (L)
Голосование за одобрение для каждого участника + карточка DingTalkновая обвязка bridge + respondToPermissionФаза 1/2 (L)

6.2 Проактивный движок: планировщик + исходящий push (ЯДРО)

Решение: планировщик, принадлежащий шлюзу, нейтральный к миграции

Используйте планировщик, работающий в процессе шлюза qwen channel start. Шлюз владеет SessionRouter (с механизмом восстановления restoreSessions()start.ts:275,444), содержит каждый экземпляр адаптера и его bridge, и это единственное место, где можно вызвать ChannelBase.pushProactive() (и базовый абстрактный sendMessage(), :81). Агент (будь то запущенный дочерний процесс --acp в Фазе 0 или сессия демона в Фазе 1+) остается чистым исполнителем промптов: планировщик срабатывает, ставя задачу в очередь ChannelBase.sessionQueues, которая вызывает bridge.prompt() только после завершения предыдущего хода — никаких новых методов bridge, никаких обратных каналов, никаких маршрутов push от демона.

Примечание по топологии (утвержденная архитектура). Планировщик изначально нейтрален к миграции: он сериализует запросы через ChannelBase.sessionQueues независимо от того, какой bridge используется под капотом. В Фазе 0 он управляет AcpBridge.prompt() через stdio; в Фазе 1+ — DaemonChannelBridge.prompt() (размещенным в демоне). Поскольку аудит eventBus демона и FIFO promptQueue необходимы для управления в Фазе 1+, канал запускается под qwen serve начиная с Фазы 1, но собственная логика планировщика на границе миграции не меняется.

Почему не альтернативные варианты:

  • Внутри-Session cron: отклонено — cronQueue/cronProcessing живут в внутрипроцессном Session (Session.ts:667-668), срабатывают только пока сессия открыта, и умирают при dispose() во время очистки неактивных сессий через 30 минут (:790-812). Именно эту проблему и избегает планировщик шлюза. Более того, планировщик шлюза является ЕДИНСТВЕННЫМ владельцем cron (OD-8): тег-сессия никогда не запускает свой внутри-сессийный cron (механизм блокировки описан ниже).
  • Отдельный процесс: отклонено — второй долгоживущий процесс дублирует учетные данные DingTalk и не может переиспользовать внутрипроцессный SessionRouter и уже подключенный bridge.

Компоненты и их размещение

КомпонентФайлОтветственность
ChannelCronStorepackages/channels/base/src/ChannelCronStore.ts (новый)Долговременная таблица задач, JSON-файл, соседствующий с sessions.json. atomicWriteJSON (atomicFileWrite.ts:385) + async-mutex Mutex для каждого файла.
ChannelCronSchedulerpackages/channels/base/src/ChannelCronScheduler.ts (новый)Единственный перезапускаемый setTimeout (таймер-колесо-из-одного); время следующего срабатывания через nextFireTime; перезапуск с догоняющей обработкой; тик согласователя каждые 60 с. Один на шлюз; единственный владелец cron.
Примитивы Cronpackages/core/src/utils/cronParser.ts (переиспользование)parseCron/matches/nextFireTime (:104,141,168). Не переписывать.
dispatchProactiveChannelBase.ts (расширение)Внедрение срабатывания через sessionQueues; ожидание завершения любого активного хода пользователя через activePrompts.get(sessionId)?.done; никогда не использовать steer; никогда не вызывать bridge.prompt(), пока удерживается activePrompts.
pushProactiveChannelBase.ts (расширение; базовое значение = sendMessage) + переопределение для DingTalkИсходящая доставка; переопределения DingTalk для холодных групп. Ограничивается возможностью canColdSend.
canColdSendСвойство ChannelBase (по умолчанию false)Флаг возможности, который планировщик проверяет перед холодной отправкой; DingTalk переключает его в true, как только будет готов путь проактивного API; для Feishu он true.
Проактивная отправка DingTalkpackages/channels/dingtalk/src/proactive.ts (новый) + DingtalkAdapter.tsПроактивная рассылка сообщений через robotCode + сохраненный openConversationId (контракт ПРОВЕРЕН ниже).
Подключениеstart.ts (расширение startSingle/startAll)Создание и запуск планировщика после router.restoreSessions() (:275,444); передача флага isTagSession в конструктор сессии (OD-8).
/schedule + инструмент schedule_taskChannelBase.handleInbound() (расширение, после проверок :240-252)Сначала детерминированная команда; затем инструмент модели.

Флаг возможности canColdSend (Исправление #4)

Кроссплатформенный критерий MVP («одна и та же задача доставляется в DingTalk и Feishu») требует флага возможности, чтобы планировщик мог рассуждать о доступности, а не узнавать о ней путем молчаливых сбоев.

  • Объявлен как свойство в ChannelBase: protected readonly canColdSend: boolean = false;. (Размещен в базовом классе, а не в отдельном реестре ChannelPlugin, потому что планировщик уже содержит экземпляр адаптера, а pushProactive/sendMessage являются методами экземпляра — совместное размещение флага и метода, который он защищает, оставляет их в рамках одного типа.)
  • DingTalk: canColdSend = false до тех пор, пока не будет выпущен путь проактивной отправки (proactive.ts) и не будет сохранен рабочий openConversationId; переключается в true после реализации pushProactive. Пока значение false, DingTalk все еще может отвечать на «теплые» (webhook) ходы — canColdSend управляет только доставкой в холодные группы.
  • Feishu: canColdSend = true (нативная проактивная отправка через tenant_access_token, FeishuAdapter.ts:622-676).
  • Планировщик сообщает об ошибках явно: перед выполнением срабатывания планировщик проверяет adapter.canColdSend. Если false, он не пытается выполнить pushProactive; он логирует видимую для оператора ошибку, устанавливает job.lastStatus='error' + lastError='adapter cannot cold-send', выводит ее в /schedule list и (согласно политике) инкрементирует consecutiveFailures. Он никогда не завершается без действий.

Раздельные хранилища cron + блокировка OD-8 (Исправление #5)

Существует два пути сохранения cron, и они находятся на непересекающихся путях в файловой системе, поэтому они никогда не смогут читать или писать одни и те же задачи:

  • Хранилище шлюза (новое): path.join(Storage.getGlobalQwenDir(), 'channels', 'cron.json') — глобальное для канала, соседствует с sessionsPath() (start.ts:56-58), принадлежит пользователю, находится вне рабочего дерева.
  • Хранилище сессии (существующее): внутри-сессийный cron Session использует хешированный по проекту каталог ~/.qwen/tmp/<hash>/scheduled_tasks.json (cronTasksFile.ts:1-9).

Поскольку пути не пересекаются, единственный способ, при котором долговременная задача может сработать дважды, — это если тег-сессия также запустит свой внутри-сессийный Session cron в дополнение к планировщику шлюза. OD-8 закрывает эту лазейку: планировщик шлюза является единственным владельцем cron; сессия, размещенная в канале («тег-сессия»), не запускает свой внутри-сессийный cron.

Механизм блокировки — как сессия узнает, что она является тег-сессией. Тег-сессия создается с явным флагом, передаваемым от хоста канала:

  • На пути демона Фазы 1+ DaemonChannelSessionFactory уже получает структурированный объект опций ({ workspaceCwd, modelServiceId, sessionScope }, DaemonChannelBridge.ts:226-241). Добавьте isTagSession: true в этот объект; демон Session считывает его при создании и пропускает startCronScheduler() (место вызова, которое в противном случае активировало бы cronQueue, Session.ts:667-668). Очистка (disposal) уже удаляет cron при сборке (:790-803), поэтому тег-сессия просто никогда его не активирует.
  • На пути AcpBridge Фазы 0 дочерний агент также не должен активировать внутри-сессийный cron для тег-рабочего пространства; передайте тот же флаг через опцию запуска --acp (новое поле AcpBridgeOptions, пробрасываемое как флаг в Config). Пока эта передача флага не реализована, Фаза 0 просто не регистрирует никаких внутри-сессийных задач cron (команда /schedule обращается к хранилищу шлюза), поэтому нечему срабатывать дважды.

Это сводит оставшийся риск к чисто операционному: «не запускать оба планировщика для одних и тех же задач» — и блокировка гарантирует, что тег-сессия никогда не запустит второй планировщик.

Схема долговременного хранилища и восстановление при перезапуске

Схема аналогична DurableCronTask (cronTasksFile.ts:19-26: id/cron/prompt/recurring/createdAt/lastFiredAt — поле называется cron, а не cronExpr):

interface ChannelCronJob { id: string; // randomUUID() channelName: string; target: { // повторяет SessionRouter PersistedEntry (SessionRouter.ts:5-9) channelName: string; senderId: string; // "__cron__" для системных задач chatId: string; // DingTalk openConversationId — ДОЛГОВРЕМЕННЫЙ id холодной группы threadId?: string; }; cwd: string; // проверяется == привязанное рабочее пространство при загрузке cron: string; // 5 полей (parseCron) ИЛИ "@once:<epochMs>" prompt: string; label?: string; recurring: boolean; enabled: boolean; createdBy: string; // senderId; рекомендательный при модели с одним токеном; переносится в атрибуты срабатывания createdAt: number; lastFiredAt: number | null; lastStatus?: 'ok' | 'error' | 'skipped'; lastError?: string; consecutiveFailures: number; // автоотключение после N (например, 5) }

Запись через atomicWriteJSON под async-mutex Mutex для каждого файла. Восстановление при перезапуске в start.ts после router.restoreSessions() (:275/:444):

  1. bridge.start()restoreSessions() перезагружает sessions.json и вызывает bridge.loadSession() для каждой записи.
  2. store.load(); отбрасывает записи, у которых cwd !== boundWorkspace.
  3. scheduler.start(): вычисляет nextFireTime(job.cron, new Date()) для каждой включенной задачи. Политика пропущенных срабатываний (решение RFC): периодические задачи, просроченные во время простоя, срабатывают один раз немедленно, а затем возобновляют работу — никогда не воспроизводят накопившуюся очередь (поток накопившихся задач в активную группу — это инцидент со спамом). Одноразовые задачи в прошлом срабатывают один раз, а затем удаляются. cronScheduler.ts различает { kind: 'catch-up'; ids } (периодические) и { kind: 'missed'; tasks } (одноразовые, требуют подтверждения) в :81-89,608-707; мы принимаем объединение в одно срабатывание для периодических.
  4. Установите один setTimeout на ближайшую задачу; переустанавливайте после каждого срабатывания. Добавьте тик согласователя каждые 60 с (прецедент: lockProbeTimer, cronScheduler.ts:229,507-538), пересчитывающий время от Date.now(), чтобы компенсировать рассинхронизацию часов при приостановке/возобновлении — никогда не накапливайте интервалы.

Путь срабатывания: внедрение в ОБЩУЮ сессию группы (Исправление #1 — самое важное)

Инвариант «один активный промпт на сессию» различается в зависимости от топологии, и dispatchProactive в v1 ошибся для пути демона:

  • Фаза 0 (AcpBridge): AcpBridge.prompt() (:147-180) не имеет собственного механизма защиты от параллелизма; единственная сериализация — это ChannelBase.sessionQueues/activePrompts (:29-35,394,466) и собственная ACP-сессия дочернего процесса --acp.
  • Фаза 1+ (DaemonChannelBridge): DaemonChannelBridge.prompt() выбрасывает Prompt already in flight, если activePrompts.has(sessionId) (:257-261) — он не ставит в очередь. FIFO promptQueue (bridge.ts:2855,3082) находится на стороне демона/acp-bridge, после этого внутрипроцессного механизма выбрасывания исключения. Поэтому вызов DaemonChannelBridge.prompt() во время активного хода пользователя выбрасывает исключение, а не ждет.

Редизайн (корректный для обеих топологий): никогда не вызывать bridge.prompt(), пока ход выполняется; сериализовать на уровне канала через sessionQueues, предварительно ожидая activePrompts. Поскольку sessionQueues выстраивает проактивный запуск после завершения предыдущего, к моменту вызова bridge.prompt() activePrompts.get(sessionId) уже очищен — поэтому на пути демона механизм выбрасывания исключения никогда не срабатывает, а на пути AcpBridge незащищенный prompt() также никогда не перекрывается.

// ChannelBase.ts — повторно использует приватные sessionQueues/activePrompts (:29-35). // Работает идентично для AcpBridge (Phase 0) и DaemonChannelBridge (Phase 1+): // цепочка гарантирует, что bridge.prompt() выполняется только после завершения предыдущего хода, // поэтому исключение `Prompt already in flight` в DaemonChannelBridge (:257-261) не может сработать. async dispatchProactive(sessionId: string, promptText: string): Promise<string> { const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve(); const run = prev.then(async () => { const active = this.activePrompts.get(sessionId); if (active) await active.done; // дожидаемся хода человека — никогда не отменяем через steer (:371-379) return this.bridge.prompt(sessionId, promptText); // только теперь activePrompts очищен }); this.sessionQueues.set(sessionId, run.then(() => {}, () => {})); return run; }

Инвариант: проактивный ход никогда не может быть отменен последующим ходом человека и никогда не отменяет ход человека. Обеспечение, сформулированное для обоих вариантов:

  • Отсутствие отмены проактивного хода человеком: dispatchProactive никогда не вызывает steer/cancelSession. Он только ожидает (await) activePrompts.get(sessionId)?.done и затем ставится в очередь после него.
  • Отсутствие отмены проактивного хода человеком: профиль группы тегов — followup (никогда steer) (§6.1). Поскольку steer — это единственный dispatchMode, вызывающий bridge.cancelSession() (:371-379), а группы тегов его никогда не выбирают, входящий ход человека может только выстроиться в очередь после выполняющегося проактивного хода через sessionQueues — он не может его отменить. (На пути демона к DaemonChannelBridge.cancelSession (:332) можно попасть только из ветки steer, которая исключена для групп тегов.)
  • Защита от исключений никогда не срабатывает: на обоих путях bridge.prompt() вызывается только в конце цепочки sessionQueues, после завершения предыдущего запуска и (для ходов человека) очистки activePrompts — поэтому исключение при перекрытии в DaemonChannelBridge (:257-261) структурно недостижимо для трафика тегов.

При срабатывании:

  1. Разрешение общей сессии через router.resolve(target.channelName, target.senderId, target.chatId, target.threadId, job.cwd) (SessionRouter.ts:72). 'thread' → один sessionId на всю группу, поэтому срабатывание происходит в контексте, который видят люди. Если восстановленная сессия была удалена, resolve() создает и сохраняет новую.
  2. Постановка в очередь, без вытеснения (followup через sessionQueues). Намеренно не используется steer.
  3. Маркер + атрибуция (Fix #7). Префикс [Scheduled task "<label>" set by <createdBy>]\n. Идентичность createdBy передается вместе с запуском в очереди, а не добавляется по таймстемпу позже, поэтому любой вызов инструмента/запрос разрешения, возникший при этом срабатывании, атрибутируется именно этому проактивному ходу (§6.4).
  4. Перехват + отправка. dispatchProactive возвращает текст завершения; планировщик проверяет adapter.canColdSend, затем вызывает channel.pushProactive(target.chatId, text) (явная ошибка, если false).

Отправка в «холодную» группу в DingTalk

Подтвержденное ограничение: DingtalkAdapter.sendMessage() отправляет сообщения только через sessionWebhook, кэшируемый для каждого conversationId (:84,134-142), который заполняется только при входящих сообщениях (:505-517). Холодная группа → молчаливый возврат (:137-141).

Исправление — pushProactive через API 主动消息 群发 (проактивная рассылка сообщений) DingTalk (контракт теперь ПОДТВЕРЖДЕН, OD-7 решен). Форма вызова также имеет прецедент в репозитории (emotionApi делает POST-запрос на api.dingtalk.com/v1.0/robot/... с заголовком x-acs-dingtalk-access-token и телом { robotCode, openConversationId, ... }, :188-197).

Подтвержденные эндпоинт и параметры (полные примечания к источникам см. в §6.5; уверенность указана для каждого пункта):

  • Эндпоинт: POST https://api.dingtalk.com/v1.0/robot/groupMessages/send (высокая уверенность; официальная документация по отправке + aliyun ask/559227).
  • robotCode (ОБЯЗАТЕЛЬНЫЙ, строка): идентификатор робота при установке его в группу; то же пространство значений, что и у appKey для внутренних корпоративных роботов → используем config.clientId (:184,435). Новые учетные данные не требуются. (высокая уверенность)
  • openConversationId (ОБЯЗАТЕЛЬНЫЙ, строка): открытый ID беседы целевой группы с префиксом cid; коды ошибок miss.openConversationId/invalid.openConversationId подтверждают, что он обязателен и валидируется. Сохраняем в ChannelCronJob.target.chatId — он стабилен при перезапусках, в отличие от sessionWebhook. (высокая уверенность)
  • msgKey (ОБЯЗАТЕЛЬНЫЙ, строка): ключ шаблона сообщения; 'sampleMarkdown' для markdown ('sampleText' для обычного текста). (высокая уверенность; документация по типам сообщений + aliyun ask/585232)
  • msgParam (ОБЯЗАТЕЛЬНЫЙ, JSON-кодированная строка, а не вложенный объект): для sampleMarkdown строка имеет вид "{\"title\":\"<заголовок для предпросмотра>\",\"text\":\"<тело markdown, макс. ~5000 символов>\"}". (высокая уверенность; поля title/text для markdown из документации по типам сообщений, пример text дословно из aliyun ask/585232)
  • coolAppCode (НЕОБЯЗАТЕЛЬНЫЙ): только если робот установлен как групповое cool app (群聊酷应用); не требуется для обычного робота внутреннего корпоративного приложения. (средняя уверенность)
  • conversationId == openConversationId? Для стандартного @-callback группы считаем, что callback conversationId (с префиксом cid) можно напрямую использовать как openConversationId — это подтверждается источниками сообщества и совпадением формата cid. Отмечено (средняя уверенность): в официальной документации нет дословной фразы, приравнивающей их для стандартного (не cool-app) робота. Гарантированный документацией путь — это API конвертации chatId → openConversationId (или получение его из API создания группы / chooseChat JSAPI / callback cool-app, который напрямую доставляет openConversationId+coolAppCode). Правило отката: если при отправке возвращается invalid.openConversationId, используем API конвертации chatId → openConversationId.
const GROUP_SEND = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'; // высокая уверенность async pushProactive(chatId: string, text: string): Promise<void> { // переопределение DingtalkAdapter const token = await this.tokenManager.get(); // обновляется независимо от жизненного цикла подключения SDK const robotCode = this.config.clientId; if (!token || !robotCode) { /* обновить один раз; иначе установить lastError + return */ return; } for (const chunk of normalizeDingTalkMarkdown(text)) { // повторно используем чанкер, ЕСЛИ лимит длины шаблона совпадает const msgParam = JSON.stringify({ title: extractTitle(text), text: chunk }); // msgParam — это СТРОКА await sendGroupMessage({ token, robotCode, openConversationId: chatId, msgKey: 'sampleMarkdown', msgParam }); // при invalid.openConversationId → конвертировать через chatId API, повторить } }

sendMessage() теперь работает так: сначала пробуем кэшированный sessionWebhook (дешево, не тратит токен); в противном случае откатываемся к pushProactive(). Базовое значение по умолчанию pushProactive = (chatId, text) => this.sendMessage(chatId, text), поэтому Feishu не требует переопределения (FeishuAdapter.sendMessage() уже выполняет проактивные отправки на любой chatId со стабильным tenant_access_token, :622-676; canColdSend = true). DingTalk — единственный отличающийся адаптер, асимметрия в пользу DingTalk. Флаг canColdSend (выше) позволяет движку явно сообщать об ошибке для адаптера, работающего только в реактивном режиме, вместо молчаливого отбрасывания.

Жесткие ограничения развертывания (не код): корпоративный бот должен быть (a) опубликованным внутренним корпоративным ботом, (b) иметь выданное разрешение на проактивные групповые сообщения, (c) быть участником целевой группы (установленным через групповое cool app / внутреннее корпоративное приложение / стороннее приложение, с его robotCode) (высокая уверенность, что разрешение должно быть включено; высокая уверенность, что установка бота + robotCode являются обязательными условиями), (d) иметь сохраненный openConversationId. Мы сохраняем conversationId при первом появлении любого входящего сообщения от бота в группе, поэтому «холодная» = неактивная, а не никогда не виденная; по-настоящему невиданная группа не может получить push, пока ее openConversationId не будет получен через API конвертации (жесткое ограничение). Необходимое изменение адаптера: сегодня кэшируется только sessionWebhook (:516-517); мы также должны сохранять conversationId (рекомендуемое хранилище: отдельный ~/.qwen/channels/dingtalk-groups.json, не связанный с временем жизни сессии, чтобы можно было представлять холодные группы и cron без живой сессии).

ВСЕ ЕЩЕ ОТМЕЧЕНО (низкая уверенность) — оставить на виду согласно OD-7: (1) точный код/отображаемое имя точки разрешения для «проактивной отправки группового сообщения» в консоли 权限管理 (управление разрешениями) приложения DingTalk не зафиксирован в документации — DingTalk показывает его в 权限管理 приложения как разрешение робота/отправки сообщений (обычно семейство robot-message, например, qyapi_robot_sendmsg / 企业机器人发送消息权限); подтвердите в консоли, не делайте жестких утверждений о коде. (2) Авторитетная единственная официальная фраза, приравнивающая callback conversationId к openConversationId для стандартного (не cool-app) робота, не была найдена дословно в этой сессии — высоковероятный ярлык, но гарантированный документацией путь получения — это API конвертации chatId → openConversationId. Страницы открытой платформы DingTalk рендерятся через JS и не могли быть полностью проскраплены в этой сессии; факты об эндпоинте/параметрах/токене были перекрестно подтверждены через зеркало документации apifox и Q&A разработчиков Aliyun, цитирующие официальные примеры запросов.

Аутентификация и жизненный цикл токена (подтверждено; критический риск осуществимости)

Заголовок авторизации (высокая уверенность). Все вызовы v1.0 (включая groupMessages/send) передают токен в заголовке запроса x-acs-dingtalk-access-token: <accessToken> плюс Content-Type: application/json — ровно тот же заголовок, который уже используют emotionApi() (:188-207) и downloadMedia() (media.ts:36-43).

Получение токена (высокая уверенность). Внутреннее корпоративное приложение, стиль v1.0: POST https://api.dingtalk.com/v1.0/oauth2/accessToken с JSON-телом {"appKey":"<appKey>","appSecret":"<appSecret>"}{ "accessToken": "...", "expireIn": 7200 }. (Устаревший эквивалент GET https://oapi.dingtalk.com/gettoken?appkey=..&appsecret=.. возвращает {access_token, expires_in:7200}, но этот устаревший токен предназначен для старых эндпоинтов oapi; для API v1.0 api.dingtalk.com используйте токен v1.0 accessToken в заголовке x-acs-dingtalk-access-token.)

Срок действия и кэширование (высокая уверенность). Токены истекают через 7200 с (~2 ч) и ДОЛЖНЫ быть повторно получены после истечения срока действия; в пределах окна валидности повторные запросы возвращают тот же токен и обновляют его. Кэшируйте для каждого приложения; не вызывайте эндпоинт токенов при каждом запросе (частые запросы попадают под троттлинг).

Почему это критический риск. Stream SDK получает access_token один раз при подключении через GET .../gettoken внутри getEndpoint() (client.mjs:85-87) и никогда его не обновляет; getAccessToken() возвращает кэшированное значение (DingtalkAdapter.ts:172-174). autoReconnect повторно запрашивает его только при закрытии сокета (client.mjs:157-163) — стабильный долгоживущий сокет хранит устаревший токен после истечения ~2-часового TTL, и любая проактивная отправка (а также существующие пути emotion/media) молча завершается ошибкой после его истечения. Проктивная функция должна сама управлять обновлением токена: tokenManager, который запрашивает токен через эндпоинт v1.0 oauth2/accessToken по таймеру (до истечения ~2 ч) и/или при получении 401, кэшируя его для каждого приложения независимо от жизненного цикла подключения SDK (OD-7). Это наиболее вероятная причина сбоя по сценарию «работает на демо, умирает через 2 часа».

Ограничения частоты (подтверждено, смешанная уверенность — оставить отмеченным): (1) параллелизм серверного API на приложение ~20 QPS в DingTalk Standard, с месячной квотой Open API ~10 000/мес (Professional ~500 тыс., Dedicated ~5 млн) (средне-высокая). (2) Часто цитируемый лимит 20 сообщений/минуту → ~10-минутный троттлинг на робота задокументирован для кастомных роботов групповых вебхуков; он обычно применяется как практическое руководство для пути отправки робота orgapp, но не был явно подтвержден на странице groupMessages/send в этой сессии — относитесь к точной цифре 20/мин для groupMessages/send с низкой/средней уверенностью. Также: не вызывайте эндпоинт токенов слишком часто (отдельный троттлинг). Планировщик должен консервативно ограничивать частоту собственных отправок и отступать при ответах с троттлингом.

Постоянные инструкции (повторяющиеся запросы на естественном языке → хранилище → потребление)

Двухуровневый перехват в handleInbound() после прохождения проверок (:240-252): явная команда /schedule "0 9 * * 1-5" post the open PR list (парсится с помощью parseCron, без обхода через модель) и инструмент модели Phase-2 schedule_task(cron, prompt, recurring, label). Оба вызывают store.add({...}) → сохранение → scheduler.reschedule(job), затем отвечают в канал. /schedule list|cancel <id>|disable <id> читают/пишут в хранилище. Сохранение в режиме fail-closed: отказываться подтверждать /schedule, если запись вызывает исключение.

Сценарии сбоев

  • Шлюз недоступен во время срабатывания: восстановление объединяет просроченные периодические срабатывания в одно догоняющее; прошедшие одноразовые срабатывают один раз, затем удаляются.
  • Сбой агента во время срабатывания: bridge.prompt() отклоняется; attachDisconnectHandler (start.ts:241,403) перезапускает (Фаза 0) / демон переподключается (Фаза 1+). Планировщик устанавливает lastError, не обновляет lastFiredAt для периодических задач → повторная попытка. Гарантия “хотя бы один раз”; ключ срабатывания, округленный до минуты, + дедупликация по lastFiredAt.
  • Сессия очищена / loadSession завершается ошибкой: resolve() создает новую (история группы потеряна; постоянные инструкции должны быть самодостаточными). Память канала (§6.3) служит базовым уровнем восстановления.
  • Адаптер не может выполнить холодную отправку (canColdSend=false): планировщик логирует и записывает lastError, что отображается в /schedule list; никогда не остается без уведомления.
  • Холодная отправка в удаленную группу или группу с отозванными правами: не-2xx → lastError; invalid.openConversationId → попытка конвертации chatId → openConversationId + один повтор.
  • Истек срок действия токена: tokenManager обновляет один раз + использует backoff; consecutiveFailures ≥ N → автоматическое отключение с записью, видимой оператору.
  • Два шлюза в одном рабочем пространстве: checkDuplicateInstance() (start.ts:170-179) гарантирует единственный экземпляр; дополнительно записывает токен блокировки в cron.json.

6.3 Память и обучение в рамках канала (Область сборки 3)

Тег должен запоминать группу с течением времени, не просачиваясь в соседние группы. Сегодня память qwen-code является глобальной для рабочего пространства: нет оси чат/канал/группа/сессия.

Факты о топологии / зависимостях (Fix #3). Архитектура определяется двумя жесткими ограничениями: (1) В топологии AcpBridge по умолчанию нет демона qwen serve и нет маршрута POST /workspace/memory — у дочернего процесса --acp нет HTTP-клиента; даже после миграции на демон в Фазе 1+ маршрут памяти остается только для демона и со строгой аутентификацией (deps.mutate({ strict: true }), workspace-memory.ts:114). (2) @qwen-code/channel-base зависит только от @agentclientprotocol/sdk (packages/channels/base/package.json), а не от @qwen-code/qwen-code-core, поэтому ChannelBase не может выполнить import { writeWorkspaceContextFile }. Следовательно, в исправленной архитектуре память канала записывается/читается внутри процесса через вспомогательную функцию ядра, доступную из channel-base через callback-и, внедряемые слоем CLI (packages/cli, который может зависеть от ядра) — не по HTTP и без добавления зависимости от ядра в channel-base.

Текущее состояние: две области видимости, ни одна не привязана к разговору

POST /workspace/memory принимает только scope: 'workspace' | 'global' (workspace-memory.ts:118-125), разрешая путь через resolveContextFilePath() (writeContextFile.ts:223-240): workspace → <root>/QWEN.md, global → ~/.qwen/QWEN.md. Режим добавления (append) сворачивается под ## Qwen Added Memories (MEMORY_SECTION_HEADER, const.ts:29); файловый мьютекс с таймаутом 30 сек сериализует записи (writeContextFile.ts:48-57,159-162); запись отклоняется, если существующий файл > 16 МБ при добавлении (MAX_EXISTING_FILE_BYTES, :255). Маршрут использует строгую аутентификацию (deps.mutate({ strict: true }), :114) — отклоняет запросы даже на loopback без токена. Следствие: все группы в одном рабочем пространстве используют один общий QWEN.md.

Дизайн: область памяти channel с ключом (channelName, chatId)

Единицей изоляции является цель маршрутизации, а не сессия (сессии очищаются при простое, DEFAULT_SESSION_IDLE_TIMEOUT_MS 30 мин, run-qwen-serve.ts:94). Ключ уже существует: SessionTarget { channelName, senderId, chatId, threadId } (types.ts:88-93). Для памяти группы ключом служит (channelName, chatId).

Структура хранилища повторяет существующее дерево ~/.qwen/channels/:

~/.qwen/channels/ sessions.json memory/ <channelName>/ # sanitize: reject /, .., NUL <hash(chatId)>/ # sha256(chatId).slice(0,16) — path-safe, no collision/escape QWEN.md # group-scoped "learning over time" meta.json # { channelName, chatId, displayName?, createdAt, lastWriteAt }

Имя файла соответствует getCurrentGeminiMdFilename() (const.ts:49). Это удерживает память канала вне рабочего дерева, вне привязанного рабочего пространства и вне пути иерархического обнаружения QWEN.md (чтобы она никогда не просачивалась между группами).

Путь записи (расширяем вспомогательную функцию ядра, не создаем форк)

В packages/core/src/memory/writeContextFile.ts:

  • Расширяем WriteContextFileScope (:80) с 'workspace' | 'global', добавляя 'channel'.
  • Расширяем WriteContextFileOptions (:83-97), добавляя channelKey?: { channelName: string; chatId: string }; проверяем наличие, когда scope === 'channel' (аналогично защите абсолютного пути :142-146). projectRoot остается обязательным в интерфейсе — передаем config.cwd, даже если он не используется для области канала.
  • В resolveContextFilePath() (:223-240) добавляем ветку channel, возвращающую path.join(Storage.getGlobalQwenDir(), 'channels', 'memory', sanitize(channelName), hash(chatId), getCurrentGeminiMdFilename()). Текущая сигнатура функции — (scope, projectRoot) — в нее нужно добавить параметр channelKey (приватная функция, локальное изменение). Файловый мьютекс использует разрешенный путь в качестве ключа, поэтому две группы могут писать параллельно без конфликтов.

Точное изменение ChannelBaseOptions + кто их внедряет (Fix #3). channel-base не может импортировать ядро, поэтому слой CLI предоставляет чтение/запись в виде callback-ов. Расширяем объект опций (ChannelBase.ts:9-12 — реальный интерфейс сегодня это просто { router?: SessionRouter; proxy?: string }; config и bridge являются позиционными аргументами конструктора в :40-46, а не членами объекта). Объект уже содержит router:

// packages/channels/base/src/ChannelBase.ts — ChannelBaseOptions (NO new core dependency) export interface ChannelBaseOptions { // ...existing members today: router?: SessionRouter; proxy?: string /** Read this channel's distilled memory; null if none yet. Injected by the CLI layer. */ readChannelMemory?: (target: SessionTarget) => Promise<string | null>; /** Append/replace this channel's memory. Injected by the CLI layer. */ writeChannelMemory?: ( target: SessionTarget, content: string, mode: 'append' | 'replace', ) => Promise<void>; }

Кто их создает и внедряет: packages/cli/src/commands/channel/start.ts (который зависит от ядра). Когда start.ts формирует объект опций для каждого адаптера, он замыкается на writeWorkspaceContextFile ядра / вспомогательной функции чтения и разрешает доверенный сервером (channelName, chatId) из router.getTarget(sessionId) (SessionRouter.ts:94) — адаптер никогда не передает chatId из сети:

// packages/cli/src/commands/channel/start.ts — CLI layer (CAN depend on core) import { writeWorkspaceContextFile, readChannelContextFile, } from '@qwen-code/qwen-code-core'; const baseOpts: ChannelBaseOptions = { router, // config & bridge are positional args of createChannel(name, config, bridge, baseOpts) — not bag members readChannelMemory: (target) => readChannelContextFile({ channelKey: { channelName: target.channelName, chatId: target.chatId }, }), writeChannelMemory: (target, content, mode) => writeWorkspaceContextFile({ scope: 'channel', channelKey: { channelName: target.channelName, chatId: target.chatId }, mode, content, projectRoot: config.cwd, // projectRoot unused for channel scope but required by the interface }), }; // adapter is created positionally with the bag last: plugin.createChannel(name, config, bridge, baseOpts)

Адаптер никогда не обращается к файловой системе, а channel-base не получает новых зависимостей. (Альтернатива для демона в Фазе 2: ограниченный маршрут POST /channel/:sessionId/memory, который разрешает channelKey на стороне сервера; он не может переиспользовать POST /workspace/memory, который жестко валидирует scope ∈ {workspace, global} и передает фиксированный projectRoot, :118-125,185-190. Отложить до тех пор, пока проактивному движку действительно не понадобится поиск sessionId → target на стороне демона.)

Трансляция событий. publishWorkspaceEvent находится на стороне демона в AcpSessionBridge (bridge.ts:3610), а не на стороне канала. В AcpBridge (Фаза 0) нет события memory_changed (и оно не нужно — один процесс владеет и записью, и чтением). В топологии с демоном publishWorkspaceEvent транслируется на каждую активную шину сессий без разбора (bridge.ts:3649-3675); BridgeEvent.data имеет произвольную форму (eventBus.ts:51), поэтому событие memory_changed может содержать { scope:'channel', channelName, chatId }, но требуется фильтрация на стороне подписчика — издатель не может ограничить доставку.

Путь чтения (память → промпт) — одноразовая за сессию инициализация с переиспользованием instructedSessions

Расширяем блок instructions, выполняемый один раз за сессию (ChannelBase.ts:343-347, управляется через instructedSessions): при первом сообщении сессии, цель которой имеет (channelName, chatId), вызываем внедренный readChannelMemory(target) и добавляем его результат перед config.instructions, затем помечаем сессию в instructedSessions точно так же, как сегодня. Поскольку область 'thread' использует один sessionId, это загружает память один раз за время жизни сессии (тот же барьер, который уже предотвращает повторное внедрение config.instructions). Зависимость от ядра не добавляется — чтение проходит через внедренный callback. Память канала никогда не находится на пути иерархического обнаружения; она внедряется для каждой сессии через этот хук.

// ChannelBase.handleInbound() — first-turn bootstrap (reuses instructedSessions) if (!this.instructedSessions.has(sessionId)) { const parts: string[] = []; if (this.options.readChannelMemory) { const mem = await this.options.readChannelMemory(target); // target from router.getTarget(sessionId) if (mem) parts.push(mem); } if (config.instructions) parts.push(config.instructions); if (parts.length) promptText = `${parts.join('\n\n')}\n\n${promptText}`; this.instructedSessions.add(sessionId); }

Связь с сохранением/восстановлением SessionRouter и историей

СлойСохраняетВремя жизниВладелец
История сессииОбращения в диалоге ACPДо очистки / /clear confirm / перезапускаSession (агент)
Сохранение SessionRouterkey → { sessionId, target, cwd } (:5-9,224-244)Сквозь перезапуск моста, через loadSession()SessionRouter (sessions.json)
Память канала (новая)Сжатые долговременные факты о группеБессрочно~/.qwen/channels/memory/

Когда restoreSessions() не может перезагрузить сессию (:196), история теряется, но групповой QWEN.md остается нетронутым — инициализирующее чтение восстанавливает знания агента при следующем сообщении. Память канала служит базовым уровнем восстановления истории. “Обучение с течением времени” — это цикл дистилляции, а не прямое сохранение истории: агент (или запускаемая задача) периодически суммирует важные факты в групповой QWEN.md в режиме добавления.

Изоляция, размер и поэтапность

Изоляция обеспечивается на уровне путей (sales и eng разрешаются в разные директории/файлы/мьютексы с hash(chatId)), пока путь записи всегда передает доверенный сервером chatId. Это изоляция контента, а не граница аутентификации (процесс по-прежнему имеет один глобальный токен, без идентификации пользователей). Для жесткой изоляции тенантов запускайте один процесс на рабочее пространство/тенант (OD-2).

Ограничения размера (переиспользуем существующий механизм): лимит 16 МБ для существующего файла при добавлении наследуется бесплатно (отображаем WorkspaceMemoryFileTooLargeError как видимое пользователю “память группы переполнена, запустите проход уплотнения”); маршрут Фазы 2 переиспользует лимит 1 МБ на запись (MAX_MEMORY_CONTENT_BYTES, workspace-memory.ts:79); уплотнение в режиме замены (writeContextFile.ts:202-211) — это долгосрочное решение проблемы неограниченного роста.

  • Фаза 0/1: добавляем область channel + channelKey в writeContextFile.ts; выпускаем ~/.qwen/channels/memory/ + meta.json; подключаем callback-и readChannelMemory/writeChannelMemory слоя CLI через ChannelBaseOptions и инициализирующее чтение, описанное выше. Никаких новых HTTP-маршрутов, никаких зависимостей channel-base → core.
  • Фаза 2: добавляем ограниченный маршрут POST /channel/:sessionId/memory (топология с демоном) и memory_changed с фильтрацией на стороне подписчика; добавляем триггер дистилляции и CLI-команду qwen channel memory <name> <chatId>. Ограничение дистилляции: cron привязан к сессии и завершается при dispose() (Session.ts:791,799-803,1056); дистилляция должна срабатывать, пока сессия активна — по завершении хода, по явной команде /remember или в сессии в режиме keep-warm — никогда из независимого фонового планировщика.

6.4 Governance: бюджеты токенов и журнал аудита (Build Area 4)

Агенту, работающему в канале, которым может управлять любой участник и который может действовать проактивно, необходимы лимиты расходов, журнал аудита, фиксирующий, кто и что запросил, а также изоляция на уровне идентичности. В qwen-code реализованы три из четырех примитивов: rate-limit.ts (токен-бакеты для каждого ключа), кольцевой буфер permission-audit.ts и MultiClientPermissionMediator. Данный раздел объединяет их и заполняет пробелы (отсутствует бюджет затрат; ни одна строка аудита не содержит информацию о реальном пользователе). Основной принцип: отклонять, а не обрезать — однако, согласно Fix #6, оценочный бюджет никогда не жестко отклоняет промпт пользователя; он лишь выдает WARNING.

Какой процесс отвечает за governance?

DeploymentBridgeКакой функционал serve/ доступен
Phase 0 — qwen channel start / AcpBridgeсоздает собственный дочерний --acp stdio-процесс (start.ts:213,356)Отсутствует. Нет сервера Express, нет rate-limit.ts, нет HTTP-маршрутов, нет кольцевого буфера permission-audit.ts.
Phase 1+ — qwen serve + DaemonChannelBridgeканалы размещаются в демонеВесь функционал serve/: реальное использование, медиатор, rate-limit, кольцевой буфер аудита, маршруты.

Решение: проверка бюджета (admission) и отклонение реализованы в @qwen-code/channel-base (общая точка входа ChannelBase.handleInbound()), в новом модуле packages/channels/base/src/BudgetLedger.ts, а не в serve/budget.ts, поскольку процесс канала Phase-0 никогда не загружает serve/, и уровень канала — это единственное место, где доступен контекст реального отправителя. Аудит и атрибуция также берут начало на уровне канала. В пути демона Phase-1+ ledger считывает реальное использование и дополнительно предоставляется через маршрут; в пути Phase-0 он выполняет оценку и предоставляется через команду канала (/audit).

Где сегодня подключается governance (и существующие пробелы)

ЗадачаСуществующий механизмПробел
Ограничение частоты запросовтокен-бакеты для каждого (clientId|ip), 3 уровня (rate-limit.ts)Нет учета токенов/затрат, только количество запросов; только в serve/
Журнал решений постфактумограниченный FIFO-кольцевой буфер, 5 типов записей (permission-audit.ts)Нет человеческого senderId, только clientId; нет GET-маршрута; кольцевой буфер удерживается замыканием (:17-25)
Реальное одобрение для каждого действиячетыре политики + кворум консенсуса (permissionMediator.ts:621-637)Голоса привязаны к clientId, а не к человеку; один канал = один клиент
Область действия инструментов/данных для каждого каналаcoreTools/allowedTools/excludeTools (config.ts:727-729); getPermissionsAllow() (:3158); getPermissionsDeny() (:3182); фильтр разрешений MCP (:3327-3333)Область действия привязана к Config/процессу; нет пути передачи аргументов spawn в дочерний --acp-процесс

Два структурных факта: (1) у демона нет человеческой идентичности (BridgeEvent.originatorClientId, каждый PermissionVote.clientId — это транспортные идентификаторы; senderName сохраняется только до SenderGate.check()), поэтому любая корреляция человек↦clientIdsessionId должна устанавливаться на границе канала; (2) аутентификация и rate-limit глобальны для демона (один bearer-токен auth.ts:259-266; rate-limit привязан к (clientId, ip)), поэтому governance для каждого канала должно начинаться в адаптере.

Бюджеты токенов и затрат — новый BudgetLedger, рекомендательный режим до появления реального использования (Fix #6)

Откуда берется использование — оговорка (OD-9). Бюджет токенов может списывать реальные значения только после того, как модель сообщит об использовании. В рамках сессии Session.#recordPromptTokenCount() (Session.ts:2078-2087) сохраняет usageMetadata.promptTokenCount в lastPromptTokenCount, перезаписывая его каждый ход, — это не накопительный счетчик для биллинга. В пути Phase-0 AcpBridge поток ACP session/update не несет usageMetadata, поэтому v1 не может списывать реальные количества токенов на этом пути. В пути демона Phase-1+ демон наблюдает использование внутри процесса и может точно его списывать.

Правило применения (Fix #6 — критически важное):

  • Оценочные бюджеты носят ТОЛЬКО РЕКОМЕНДАТЕЛЬНЫЙ характер. Когда единственное доступное значение — это оценка на стороне канала (количество символов промпта+ответа ÷ константа символов на токен), ledger выдает WARNING/алерт при достижении пороговых значений и может прикреплять предупреждение к ответу — он никогда жестко не отклоняет промпт пользователя. Ложноположительная оценка не должна блокировать реальный запрос пользователя.
  • ЖЕСТКОЕ отклонение только по реальным числам. Бюджет может отклонить промпт (отклонить, а не обрезать) только в том случае, если источник списания — это реальный путь использования демона (размещенный в демоне Phase-1+). До этого момента бюджет — это наблюдаемость + алертинг, а не шлюз.

Это делает бюджет v1 честным: он заранее предупреждает везде и применяет жесткие лимиты именно там, где значения достоверны.

Модуль BudgetLedger.ts, смоделированный по образу rate-limit.ts (фабрика, Map бакетов с GC, overflow fail-open):

export type BudgetUnit = 'tokens' | 'usd'; // 'usd' = tokens × per-model rate export type UsageSource = 'estimate' | 'daemon'; // 'estimate' => advisory; 'daemon' => may hard-decline export interface BudgetLedger { // allowed=false only when source==='daemon'; estimates return allowed=true + warn flags admit(key: string): { allowed: boolean; spent: number; limit: number; advisory: boolean; }; debit( key: string, amount: number, unit: BudgetUnit, source: UsageSource, ): void; // fires threshold alerts snapshot(): Record< string, { spent: number; limit: number; ratio: number; source: UsageSource } >; reset(): void; dispose(): void; }
  • Семантика наследования по умолчанию + сводка org по принципу “строгий побеждает” (OD-9). admit(key) определяет эффективное окно с помощью фоллбэка в стиле GroupGate: channel → '*' → built-in. Промпт должен пройти как окно для каждого канала, так и сводку “org” для каждого процесса (строгий побеждает, списание с обоих). “org” = сводка этого единственного процесса; настоящий межпроцессный лимит org требует общего хранилища (вне области применения). Фиксированное ежедневное окно.
  • Алерты 75%/95%. debit() срабатывает onAlert один раз для каждого порога в каждом окне, используя идиому гистерезиса event-bus (WARN_THRESHOLD_RATIO/WARN_RESET_RATIO, eventBus.ts:101-103). Отправка алерта — это проактивная отправка — жесткая зависимость от Build Area 2 (оговорка по холодной группе DingTalk; Feishu отправляет свободно). Деградация до “прикрепить предупреждение к следующему ответу”, если проактивный канал отсутствует.
  • Отклонить, а не обрезать (только когда source==='daemon'). Проверяется при допуске (admission), до bridge.prompt() (:425). При реальном использовании и !allowed адаптер вызывает sendMessage(chatId, refusal) и возвращает управление — он не переходит на путь steer/cancel, поэтому промпт в процессе выполнения завершается, а следующий отклоняется. При оценке allowed всегда true (рекомендательный режим).
  • Затраты (usd) умножают токены на таблицу ставок для каждой модели, предоставленную оператором (qwen-code поддерживает несколько моделей; единой цены нет). Отсутствующая запись → фоллбэк на tokens + одноразовое предупреждение.
  • Конфигурация. ChannelConfig (types.ts:27-51) получает budget?: { unit; limit; windowMs; reset? }, парсится через parseChannelConfig. В пути демона ServeOptions получает --budget-org-daily/--budget-unit, а daemon-status.ts (который уже сообщает rateLimit, :295-297) получает параллельный блок budget.

Журнал аудита — человеческий senderId, передаваемый вместе с ходом (Fix #7)

PermissionAuditRing (permission-audit.ts:128-172, FIFO 512) — это правильная основа, но каждая строка привязана к clientId. Дизайн — привязка отправитель↦ход на стороне канала (RequestAttributionRing.ts, та же FIFO-структура).

Наивное соединение по временной метке неверно при followup (Fix #7). В v1 предлагалось соединять строку разрешения с “самой последней строкой атрибуции для этого sessionId, чья recordedAtMs предшествует issuedAtMs разрешения”. При followup несколько отправителей встают в очередь для одного sessionId через sessionQueues; отправитель, поставленный в очередь последним, часто не является тем, чей ход выполняется в момент срабатывания вызова инструмента/разрешения. Следовательно, соединение по временной метке систематически присваивает атрибуты неверно.

Исправление: передавать senderId ВМЕСТЕ с промптом в очереди. Когда handleInbound() ставит в очередь sessionQueues (и когда планировщик ставит в очередь проактивный запуск), элемент очереди / синтетический контекст хода несет свой собственный { senderId, senderName, requestSeq }. Атрибуция для любого вызова инструмента/разрешения, возникшего во время хода, считывается из текущего выполняемого хода (головы FIFO), а не из сканирования временных меток. Конкретно: цепочка sessionQueues устанавливает для каждого хода currentTurnAttribution.set(sessionId, {senderId, ...}) в момент, когда выполнение достигает головы (непосредственно перед bridge.prompt()), и очищает его, когда выполнение завершается; строки аудита читают эту карту. Проактивные запуски устанавливают createdBy таким же образом (§6.2 шаг 3). Это точно для выполняемого хода и не зависит от порядка постановки в очередь.

Добавьте шестой тип строки task.requested { sessionId, senderId, channelName, chatId, promptDigest, requestedAtMs } при допуске (admission), чтобы аудит отвечал на вопрос “кто начал эту задачу” даже для работы только на чтение. Объединение PermissionAuditEntry (:57-104) является закрытым, и потребители используют switch по kind, поэтому его расширение (или добавление соседнего кольцевого буфера) затрагивает каждого потребителя.

Путь запроса. Демон Phase-1+: добавить GET /workspace/audit (bearer + строгий createMutationGate, auth.ts:356), предоставляя кольцевой буфер из замыкания bridge (документация в заголовке файла предвосхищает это, :22-25). Phase-0 AcpBridge: команда канала /audit через sendMessage. Долговечность: кольцевой буфер содержит 512 записей в памяти, теряется при перезапуске — известное ограничение v1; последующее обновление (OD-11) сохраняет аудит с добавлением только в конец (append-only) в ~/.qwen.

Голосующие в консенсусе — не люди. votersAtIssue — это clientId, установленные демоном, а один канал = один clientId, поэтому “консенсус” из коробки в группе DingTalk — это консенсус между клиентами демона. Голосование на уровне людей требует реестра зарегистрированных утверждающих, сопоставляющего senderId → отдельный голос — это требование OD-3 Phase-2, а не решенная функция.

Изоляция инструментов и данных для каждой идентичности

  1. Разрешение/запрет инструментов для каждого канала. Config поддерживает coreTools/allowedTools/excludeTools (:727-729), что предоставляется через getPermissionsAllow()/getPermissionsDeny()/getCoreTools(). (Методов getAllowedTools()/getBlockedTools() нет). В Phase 0 путь AcpBridge создает дочерний процесс для каждого канала, но AcpBridgeOptions несет только { cliEntryPath, cwd, model } (:17-21), а start() передает только --acp+--model (:56-63). Для реализации области действия для каждого канала требуются НОВЫЕ поля AcpBridgeOptions, НОВЫЕ флаги --acp в Config, а также новые поля ChannelConfig. В пути демона Phase-1+ на каждый демон приходится один Config, поэтому область действия привязана к демону (к рабочему пространству, OD-2), а не к дочернему процессу канала.
  2. Область действия MCP для каждого канала. Config.getMcpServers() фильтрует по allowedMcpServers (:3327-3333), заданным при создании. Добавьте allowMcpServers?: string[] в ChannelConfig, передав их по тому же пути аргументов spawn (или в массив mcpServers, который передает AcpBridge.newSession() — жестко задан [] в :133).
  3. sessionScope как граница данных. 'thread' заставляет группу использовать одно рабочее дерево/контекст; межканальная (channel) изоляция обеспечивается ключами маршрутизации с пространством имен channelName. Изоляция для каждого отправителя внутри группы 'thread' не предусмотрена по дизайну. Честное ограничение: аутентификация использует единый глобальный для демона токен без привязки к конкретному пользователю (principal), поэтому изоляция работает на уровне канала, а не отдельного человека. Для настоящей изоляции инструментов на уровне пользователя требуется Phase-3.

Путь допуска

DingTalk inbound → ChannelBase.handleInbound() 1. GroupGate.check() + SenderGate.check() [существующий :240-252] 2. budget.admit('channel:<name>') && budget.admit('org') [НОВОЕ] ↳ source==='daemon' && !allowed: sendMessage(refusal); return (НЕ в steer/cancel) ↳ source==='estimate': allowed always true → WARN only (Fix #6) 3. enqueue onto sessionQueues WITH {senderId, senderName, requestSeq} [НОВОЕ — Fix #7] + task.requested row 4. at FIFO head, stamp currentTurnAttribution → bridge.prompt(...) [существующий :425] ↳ tool call → permission (автоматически одобряется в AcpBridge Phase 0; медиатор в daemon Phase 1+) ↳ audit row reads currentTurnAttribution[sessionId] (исполняемый ход) 5. on completion: usage known (daemon) or estimated (AcpBridge) → budget.debit(..., source) [НОВОЕ] ↳ 75%/95% alert post is proactive → depends on Build Area 2

Важные зависимости, которые нужно учесть: (1) реальное списание токенов (и, следовательно, жесткий отказ) требует пути использования демона в Phase-1+ — до этого бюджеты носят рекомендательный характер (Fix #6); (2) проактивные предупреждения о бюджете требуют Build Area 2; (3) консенсусное голосование на уровне человека и атрибуция аудита на уровне человека требуют реестра зарегистрированных утверждающих OD-3.

6.5 Платформа DingTalk (основная) + доработка Feishu

Примечание по интеграции (утвержденная архитектура). Phase 0: qwen channel start создает AcpBridge (start.ts:213,350; AcpBridge.ts:38), который запускает node <cli> --acp и предоставляет newSession(cwd)/loadSession(sessionId, cwd) (:131,137); область видимости сессии контролируется SessionRouter, а не мостом. Phase 1+: каналы размещаются в qwen serve через DaemonChannelBridge (его дефолтные значения 'thread' в :229,240; его проверка на пересечение в :257-261). Миграция утверждена и не является опциональной (§1).

Проблема истечения срока действия sessionWebhook

В режиме DingTalk Stream каждый входящий запрос доставляется с короткоживущим sessionWebhook; адаптер кэширует его по ключу conversationId (:84, заполняется в onMessage() :517), а sendMessage() (:134-170) ищет его, логируя No webhook for chatId и молча возвращая управление, если его нет (:137-141). Два критических факта для проактивного использования: (1) срок действия webhook истекает (тип SDK RobotMessageBase содержит sessionWebhookExpiredTime, constants.d.ts:13, но интерфейс DingTalkMessageData адаптера опускает его и никогда не читает — кэшированный webhook может устареть даже внутри активного окна); (2) мапа заполняется только входящим трафиком, поэтому для “холодной” группы записи нет.

Отправка в “холодные” группы через API проактивных сообщений бота (主动消息) — ПРОВЕРЕНО (OD-7)

Решение — это API проактивных сообщений бота DingTalk — POST https://api.dingtalk.com/v1.0/robot/groupMessages/send (эндпоинт подтвержден, высокий приоритет). В отличие от webhook, он адресуется через долговечный openConversationId (подтверждено, высокий приоритет), аутентифицируется с помощью заголовка x-acs-dingtalk-access-token (подтверждено, высокий приоритет — уже используется в emotionApi() :188-207 и downloadMedia() media.ts:36-43) и содержит robotCode бота (подтверждено, высокий приоритет; = config.clientId, :184,435). Тело запроса представляет собой пару msgKey/msgParam (подтверждено, высокий приоритет), где msgParam — это сама по себе JSON-строка (а не вложенный объект), например, для msgKey:'sampleMarkdown':

{ "robotCode": "ding...", // = config.clientId "openConversationId": "cid6KeBBLov...", // долговечный id группы (из входящего conversationId; конвертировать, если невалиден) "msgKey": "sampleMarkdown", "msgParam": "{\"title\":\"<preview title>\",\"text\":\"# hi\\n...markdown ≤ ~5000 chars\"}", }

Это новый метод наряду с sendMessage(), а не ее изменение (набросок в §6.2). ChannelBase.sendMessage() остается абстрактным (:81); проактивному движку требуется новый исходящий интерфейс pushProactive?(target, text) — совершенно новый и центральный платформенный результат. подтверждено [высокая достоверность] согласно официальной документации по send + aliyun ask/559227, ask/585232 + документации по типам сообщений для эндпоинта/параметров/формы msgParam.

Необходимое разрешение: перед тем как groupMessages/send заработает, корпоративному внутреннему приложению должно быть выдано разрешение робота/сообщения “отправка проактивного сообщения в групповой чат” (в документации по send указано это необходимое условие) (подтверждено, высокая достоверность: разрешение должно быть включено). ВСЕ ЕЩЕ ТРЕБУЕТ ВНИМАНИЯ (низкая уверенность): точное отображаемое имя/код точки разрешения не зафиксированы по документации в этой сессии — консоль DingTalk показывает его в разделе 权限管理 (Управление разрешениями) приложения как разрешение на отправку сообщений роботом (обычно это семейство robot-message, например, qyapi_robot_sendmsg / 企业机器人发送消息权限); подтвердите в консоли, не делайте жестких утверждений о коде. Адаптер должен логировать resp.status + тело при !resp.ok/throw — текущий пустой catch в emotionApi (:214-216) является антипаттерном, который скроет неправильную конфигурацию из-за отсутствующего разрешения.

Получение и сохранение openConversationId

Два источника: (1) сбор из входящих — каждое сообщение содержит conversationId (:506), который передается как openConversationId в emotion API (:197); сохраняем его, как только увидим. подтверждено [средняя достоверность] согласно aliyun ask/559227, ask/585233 + совпадающему формату 'cid', что callback conversationId (с префиксом cid) можно использовать напрямую как openConversationId для стандартного группового @-callback. ВСЕ ЕЩЕ ТРЕБУЕТ ВНИМАНИЯ: нет официальной дословной фразы, приравнивающей их для робота не-cool-app; гарантированный документацией путь получения — это API конвертации chatId → openConversationId (obtain-group-openconversationid), или захват из API создания группы / chooseChat JSAPI, или callback cool-app (который доставляет openConversationId+coolAppCode напрямую). Фоллбэк: при invalid.openConversationId конвертируем через API chatId и повторяем попытку. (2) события добавления бота в группу через registerAllEventListener (client.mjs:58-61): события идут по пути onEvent → onEventReceived при дефолтном topic:'*' (client.mjs:14-19,241-254), при этом адаптер устанавливает только callback робота (:107), поэтому события организации/бота в настоящее время принимаются и отбрасываются в дефолтный no-op (client.mjs:35-37). Топик события и поле openConversationId на момент установки не проверены — не хардкодьте имя события.

Сохранение. Используйте отдельное хранилище ~/.qwen/channels/dingtalk-groups.json, а не target SessionRouter: ID группы должен переживать любую сессию (холодная отправка по cron срабатывает без живой сессии), а PersistedEntry существует только после создания сессии для ключа маршрутизации — привязка идентичности группы к времени жизни сессии оставит холодные группы непредставленными.

Область видимости для многопользовательского режима включается явно, а не по умолчанию

Область видимости 'thread' (:53) — это то, что дает один общий агент на группу, но parseChannelConfig() по умолчанию устанавливает sessionScope в 'user' (config-utils.ts:91-92), что создает сессии для каждого участника. Оператор должен явно установить sessionScope: 'thread'. При установке применяются два многопользовательских последствия: (a) дефолтный dispatchMode: 'steer' отменяет текущую работу, когда любой участник отправляет сообщение (:371-379) — профиль тегов устанавливает 'followup' (§6.1); (b) пробел в атрибуции отправителя (§6.1).

Парсинг входящих @

Групповые ограничения работают: GroupGate использует envelope.isMentioned, устанавливаемый из data.isInAtList (:520). Очистка текста удаляет только первый @token (:527-529), позиционно, а не по идентичности — @qwen @alice корректно, но упоминание человека первым удалит упоминание человека. Доработка для повышения надежности будет удалять по собственному chatbotUserId бота. Контекст ответа/цитаты извлекается (extractQuotedContext(), :272-298), при этом isReplyToBot вычисляется относительно chatbotUserId (:280,292), а referencedText внедряется как [Replying to: "…"] (ChannelBase.ts:317-319). Атрибуция отправителя закрыта в §6.1 через префикс [senderName].

Рендеринг Markdown / карточек

markdown.ts уже выполняет нормализацию платформы, которую повторно использует проактивный путь: пропускание markdown-таблиц, разбиение на части по 3800 символов с балансировкой фенсов (splitChunks(); CHUNK_LIMIT=3800) и извлечение заголовка, обрезанное до 20 символов, с фоллбэком 'Reply' (extractTitle()). Повторное использование условно тем, что шаблон sampleMarkdown принимает то же подмножество markdown и тело до ~5000 символов (подтверждено, высокая достоверность — документация по типам сообщений); сохраняйте CHUNK_LIMIT ≤ этого бюджета. Потоковые интерактивные карточки (путь TOPIC_CARD, constants.d.ts:4) — аналог потоковых карточек Feishu — выходят за рамки основного этапа; проактивная версия v1 основана на markdown-сообщениях.

Доработка Feishu (кратко)

Feishu опережает именно в том аспекте, который важен: проактивная отправка встроена нативно (sendMessage(chatId, text) в любой chat_id, :622-676 — нет проблемы холодных групп; canColdSend = true), стабильный tenant_access_token с отслеживанием срока действия и обновлением (refreshToken(), :581-620 — то, что еще нужно сделать для DingTalk), гибкая подписка на события (WebSocket или HMAC webhook, :146-176) и первоклассные потоковые карточки (markdown.ts, :742-792). Но общие проблемы ChannelBase/SessionRouter — явное включение области видимости 'thread', отмена в dispatchMode, отсутствующая атрибуция отправителя, новый исходящий интерфейс — в равной степени применимы к Feishu. Feishu решает доступность, а не кто-что-сказал или один-участник-отменяет-другого. Перенос проактивного движка в Feishu напрямую переиспользует существующий sendMessage() (дефолтный базовый pushProactive); единственная новая платформенная работа — это сопоставление целевой группы движка с сохраненным chat_id и опциональная маршрутизация через путь потоковых карточек.


7. Поэтапное внедрение (Phase 0–2) и MVP

Каждый этап можно мержить независимо, он завершается демонстрационным результатом и ограничивается явными критериями приемки. Phase 0 заставляет существующий стек вести себя как общий резидентный агент — конфигурация плюс несколько небольших изменений в коде, на базе AcpBridge. Phase 1 мигрирует хостинг каналов в qwen serve (утвержденная архитектура) и добавляет проактивный движок и единственный замкнутый цикл MVP. Phase 2 добавляет память каналов, бюджеты и аудит.

Топология: утвержденная миграция демона (ранее OD-1)

Решение принято, а не ожидается: Phase 0 поставляется на AcpBridge; Phase 1+ запускает каналы под qwen serve (через DaemonChannelBridge или runner каналов демона), потому что сохранение памяти для каждой комнаты, медиатор разрешений, аудит шины событий, FIFO promptQueue и маршруты запросов бюджета/аудита — все это требует демона. Планировщик, принадлежащий шлюзу (§6.2), нейтрален к миграции — он сериализует через ChannelBase.sessionQueues независимо от моста, поэтому он поставляется в Phase 1 и не затрагивается переключением. Интеграция Phase 0 добавляет путь подключения DaemonChannelBridge (или флаг --daemon <url>), чтобы миграция стала шагом конфигурации на границе Phase-1, а не переписыванием. Обратите внимание на острую грань, вокруг которой спроектирован планировщик: DaemonChannelBridge.prompt() не ставит в очередь — он выбрасывает Prompt already in flight при пересечении (:257-261); демонный FIFO promptQueue находится на стороне acp-bridge (bridge.ts:2855,3082); сериализация на стороне канала — это ChannelBase.sessionQueues (:394), поэтому проактивный движок никогда не вызывает prompt(), пока ход активен (§6.2, Fix #1).

Phase 0 — Конфигурация + внедрение идентичности (на AcpBridge)

Цель. Группа DingTalk, где любой участник упоминает бота через @, все участники используют одну сессию, агент знает, кто говорит, и текущая задача не уничтожается последующим сообщением от коллеги.

0.1 — Конфигурационный профиль “qwen tag” (в основном settings.json):

// settings.json → channels."team-eng" { "team-eng": { "type": "dingtalk", "clientId": "$DINGTALK_CLIENT_ID", "clientSecret": "$DINGTALK_CLIENT_SECRET", "cwd": "/srv/repos/our-service", // Многопользовательский режим: ВСЯ группа использует ОДИН sessionId. routingKey → `${name}:${threadId||chatId}` (:53). // DingTalk НЕ устанавливает threadId (:541-551) → ключ откатывается к chatId = conversationId||sessionWebhook (:534). // Сообщение без conversationId будет использовать ключом ВРЕМЕННЫЙ webhook — считайте это жесткой ошибкой. "sessionScope": "thread", // groupPolicy по умолчанию "disabled" (GroupGate :13; config-utils :98) — ДОЛЖЕН быть установлен, иначе все групповые сообщения отбрасываются. // В режиме allowlist "*" НЕ является wildcard для членства (GroupGate :42); перечислите каждый chatId. "*" задает только ДЕФОЛТНЫЕ значения. "groupPolicy": "allowlist", "groups": { "cidXXXXXXXX": { "requireMention": true, "dispatchMode": "followup" }, "*": { "requireMention": true, "dispatchMode": "followup" }, }, "senderPolicy": "open", "instructions": "You are the team's shared engineering agent in this DingTalk group...", }, }

Примечания, привязанные к источнику истины: requireMention по умолчанию true (GroupGate.ts:49); sessionScope по умолчанию 'user' (config-utils.ts:92) — 'thread' представляет собой весь механизм многопользовательской работы; групповое значение по умолчанию для dispatchMode должно быть 'followup' (а не runtime-значение 'steer', :354).

0.2 — Атрибуция отправителя. Префикс [senderName] в сидах promptText (ChannelBase.ts:316), ограниченный условием isGroup, срабатывал каждый ход (не ограничивался instructedSessions), при этом новый флаг Envelope.alreadyPrefixed защищает от повторного входа в collect. См. §6.1.

0.3 — Согласование dispatchMode. Явно задавайте dispatchMode для каждой группы; исправьте устаревший JSDoc в types.ts:42 ('collect''steer'), чтобы код и комментарий совпадали (OD-5).

Измененные файлы (Фаза 0). start.ts (добавлен опциональный путь подключения DaemonChannelBridge, чтобы коммит-миграция Фазы 1 была на расстоянии одного флага); ChannelBase.ts (сид senderName + защита alreadyPrefixed + гейт подтверждения и allowlist для /clear + /who); types.ts (новое поле Envelope.alreadyPrefixed + исправление JSDoc); docs/ (рецепт + подводные камни).

Критерии приемки.

  • Два участника @-упоминают бота; оба разрешаются в один и тот же sessionId (проверяется через мапы SessionRouter); ключ маршрутизации — team-eng:<conversationId>, а не URL вебхука.
  • Агент использует атрибуцию отправителя ([senderName] присутствует для групп, отсутствует для 1:1); повторный вход в collect не создает двойной префикс (проверяется путь alreadyPrefixed).
  • Сообщение в группу без упоминания отбрасывается (причина mention_required); сообщение из группы не в allowlist отбрасывается (not_allowlisted).
  • При dispatchMode: 'followup', если участник B пишет сообщение во время выполнения задачи участника A, задача A не отменяется; сообщение B выполняется после A.
  • В общей группе (thread) /clear требует confirm и ограничен config.allowedUsers, если они заданы (это не свободный для всех сброс); /status остается только для чтения.
  • Юнит-тесты на уровне хуков (без UI-тестов с wait(ms)): равенство ключей маршрутизации для разных отправителей; наличие префикса promptText при isGroup true и false; пропуск при alreadyPrefixed.

Фаза 1 — Миграция демона + Проактивный движок + Замкнутый цикл MVP

Определение MVP. Один замкнутый цикл с запланированным дайджестом: оператор регистрирует cron-подобную задачу для канала; при срабатывании шлюз разрешает сессию канала с областью действия thread, запускает промпт с инструментами и отправляет результат обратно в холодный канал без запроса. Одна задача, один канал, один путь доставки. Более сложное поведение выходит за рамки MVP.

Закоммиченная миграция. Фаза 1 размещает каналы под qwen serve через DaemonChannelBridge (решение OD-1), наследуя FIFO promptQueue, медиатор, eventBus и маршруты. Проактивный движок описан в §6.2 (принадлежащий шлюзу, нейтральный к миграции планировщик; dispatchProactive сериализуется через sessionQueues; фоллбэк холодной отправки DingTalk через проверенный API groupMessages/send; обновление tokenManager; флаг возможности canColdSend). Три факта делают это нетривиальным: сегодня cron привязан к сессии и умирает при dispose (закрыто гейтом единственного владельца OD-8); DingTalk не может писать в холодную группу (закрыто проверенным проактивным API + сохраненным openConversationId); и проактивный промпт должен сериализоваться через sessionQueues и никогда не вызывать bridge.prompt(), пока удерживается activePrompts — иначе DaemonChannelBridge выбросит Prompt already in flight (:257-261).

Измененные пакеты. ChannelCronStore.ts/ChannelCronScheduler.ts (новые, channel-base); cronParser.ts (переиспользование); ChannelBase.ts (dispatchProactive, pushProactive, флаг canColdSend, /schedule); DingtalkAdapter.ts + dingtalk/src/proactive.ts (новая холодная отправка + сохраненный openConversationId + tokenManager); FeishuAdapter.ts (без изменений; эталонный адаптер с поддержкой проактивности, canColdSend = true); start.ts (хостинг под демоном; создание + запуск планировщика после restoreSessions(); проброс isTagSession в создание сессии, чтобы отключить внутри сессионный cron — OD-8); создание сессии (пропуск startCronScheduler() для tag-сессий, Session.ts:667-668).

Критерии приемки.

  • Каналы работают под qwen serve (хостятся демоном); вызов инструмента выдает permission_request (медиатор доступен), что подтверждает миграцию.
  • Оператор регистрирует одну задачу дайджеста; она сохраняется при перезапуске шлюза (перезагружается из ~/.qwen/channels/cron.json).
  • Когда задача срабатывает, если нет открытой сессии, шлюз разрешает сессию с областью действия thread, запускает промпт с инструментами и доставляет результат в неактивную группу DingTalk через путь холодной отправки — доказывая возможность доставки в холодную группу. Движок громко падает (логирует, записывает lastError, не делает silent no-op) при canColdSend = false.
  • Та же задача доставляет результат в Feishu через tenant_access_token, доказывая абстракцию canColdSend.
  • Срабатывающая задача не нарушает правило одного промпта на сессию: если участник находится в процессе диалога, проактивный промпт ставится в очередь за ним через sessionQueues (await activePrompts.get(sessionId)?.done), никогда не отменяется через steer и никогда не вызывает ошибку перекрытия DaemonChannelBridge.
  • Проактивный ход не может быть отменен последующим человеческим ходом (tag-группы используют followup, никогда steer).
  • tokenManager обновляет accessToken v1.0 до истечения срока действия (~2 часа) и при ошибке 401, поэтому отправка после того, как сокет открыт > 2 часов, все равно проходит успешно.
  • Никаких двойных срабатываний для любой долговременной задачи: планировщик шлюза является единственным владельцем; tag-сессия не активирует свой внутри сессионный cron (OD-8); два хранилища находятся на непересекающихся путях.
  • Удаление задачи останавливает будущие срабатывания.
  • Тесты на уровне хуков/сервисов (планировщик против фейковых часов; холодная отправка против мокнутого HTTP-клиента) — без wait(ms).

Фаза 2 — Память канала + Бюджеты токенов + Журнал аудита

2.1 — Память с областью действия канала (§6.3): добавить область 'channel' + channelKey в writeContextFile.ts (WriteContextFileScope :80, WriteContextFileOptions :83-97, resolveContextFilePath :223-240); доставлять ~/.qwen/channels/memory/<channelName>/<hash(chatId)>/QWEN.md; подключить коллбэки readChannelMemory/writeChannelMemory CLI-слоя через ChannelBaseOptions + бутстрап чтения с переиспользованием instructedSessions. Daemon-маршрут Фазы 2 POST /channel/:sessionId/memory только для топологии демона.

2.2 — Бюджеты токенов для каждого канала (§6.4): BudgetLedger.ts с ключом по каналу, информационный (только WARN) для оценки на стороне канала, жесткий отказ только при реальном использовании демона (Fix #6/OD-9); агрегация организации на процесс + окна на канал, побеждает самый строгий, фиксированное дневное окно; алерты на 75%/95% (зависимость от проактивной отправки).

2.3 — Журнал аудита (§6.4): RequestAttributionRing + строка task.requested; атрибуция переносится с выполняемым ходом (per-turn currentTurnAttribution), а не через джойн по таймстемпу (Fix #7); команда GET /workspace/audit (демон) или /audit канала. FIFO в памяти на 512 записей, теряется при перезапуске (известное ограничение v1; follow-up с append-only в ~/.qwen, OD-11).

Измененные файлы. writeContextFile.ts, workspace-memory.ts (валидация области + GET walker, путь демона); BudgetLedger.ts, RequestAttributionRing.ts (channel-base); permission-audit.ts (источник паттернов) / новый channel-audit.ts (демон); ChannelBase.ts (перенос senderId/senderName в ходах в очереди + currentTurnAttribution; хуки бюджета); server.ts (монтирование маршрутов после express.json :2025, гейт мутаций через mutate({ strict: true })).

Критерии приемки.

  • scope: 'channel' пишет в ~/.qwen/channels/memory/<channel>/<hash(chatId)>/QWEN.md; две группы получают независимые файлы; общий QWEN.md рабочего пространства не затрагивается; запись проходит через внедренный коллбэк (нет зависимости channel-base → core).
  • Добавление в память канала идемпотентно при конкурентном доступе (мьютекс на файл) и эмитит memory_changed только при реальной мутации (путь демона; фильтрация на стороне подписчика).
  • На пути демона, после того как канал превышает лимит окна реального использования, следующий входящий промпт отклоняется (не обрезается), а проактивные задачи ставятся на паузу; счетчики сбрасываются при переходе на новое дневное окно; бюджеты независимы для каждого канала. На пути только оценки бюджет выдает WARN, но никогда не делает жесткий отказ (Fix #6).
  • Вызов инструмента/запрос разрешения, возникший во время выполнения хода отправителя A из очереди, атрибутируется A, даже если B поставил свой ход в очередь позже в режиме followup (Fix #7).
  • Каждое проактивное срабатывание, запись в память канала и событие бюджета попадают в кольцо аудита с best-effort senderId/senderName, читаемым через поверхность аудита, и не транслируются в SSE-шину.
  • Юнит-тесты кольца/маршрута/резолвера (вытеснение FIFO, разрешение путей областей, математика порогов бюджета, атрибуция выполняемого хода) — без UI/тайминговых тестов.

Границы фаз и взгляд в будущее

Фазы 0→1→2 аддитивны: многопользовательская работа + идентификация (на AcpBridge) → миграция демона + проактивный MVP → память + бюджеты + аудит. Шлюз с множественной идентификацией Фазы 3 (различные идентичности/учетные данные бота для каждого канала, настоящие принципы per-user, токены per-channel) выходит за рамки, это естественный следующий шаг, который снимает ограничения единого глобального токена / одного рабочего пространства на демон. Даже в рамках Фаз 0–2 “qwen tag” требует один процесс агента на рабочее пространство (OD-2); развертывание, обслуживающее несколько репозиториев, запускает несколько процессов.


8. qwen tag против Claude Tag (компромиссы)

Claude Tag — это хостинговый мульти-тенантный агент: Anthropic управляет runtime, идентификацией и учетом использования per-user; приложение канала является тонким клиентом. qwen tag — это обратное: он работает на инфраструктуре, контролируемой оператором, поверх адаптеров qwen-code. Эта инверсия — всё ценностное предложение и вся поверхность рисков.

Где qwen выигрывает

  • Открытый / self-hosted, данные остаются внутри. Агент работает локально — через stdio в Фазе 0 (AcpBridge.start() запускает node <cli> --acp), в процессе под qwen serve начиная с Фазы 1 — никогда через API вендора. Содержимое репозиториев, трафик моделей и транскрипты остаются на хостах оператора. Claude Tag не может этого гарантировать.
  • MCP / любой инструмент. Строгое надмножество поверхности инструментов закрытого хостингового агента.
  • Голосование за разрешения для каждого действия — возможность Фазы 1+ после хостинга под демоном. qwen-code поставляет MultiClientPermissionMediator (четыре политики, кворум консенсуса floor(M/2)+1, отдельное кольцо аудита). Подлинное конкурентное преимущество — недостижимое на пути AcpBridge Фазы 0 (requestPermission одобряет автоматически, :108-118), достижимое, когда Фаза 1 размещает каналы под демоном; даже там голоса ключуются по clientId, а канал является единственным клиентом, пока не появится реестр OD-3. Мертвое поле ChannelConfig.approvalMode (types.ts:36) подтверждает, что это было запланировано, но отсутствует.
  • Долговечное, инспектируемое состояние. Персистентность SessionRouter, простые файлы QWEN.md/AGENTS.md и (демон, Фаза 1+) кольцо воспроизведения Last-Event-ID. Ничего непрозрачного.

Где оно расходится и должно компенсировать

  1. Одно рабочее пространство + один глобальный токен + нет человеческой идентификации. Один процесс привязывается к одному рабочему пространству; несколько рабочих пространств = N процессов (OD-2). Единый глобальный токен применяется к HTTP-демону; путь канала AcpBridge Фазы 0 не имеет HTTP-поверхности и токена (его граница — SenderGate/GroupGate). Нигде нет человеческой идентификации — senderName это только информационный текст промпта (OD-11). Компенсация: один процесс на рабочее пространство/команду; внедрение атрибуции отправителя на уровне канала; сохранение clientId в качестве границы безопасности; требование --require-auth + токена для любого демона не на loopback (OD-12).
  2. Проактивная / отправка в холодные каналы неоднородна. Только реактивные ответы в DingTalk (истекающий sessionWebhook); Feishu отправляет свободно через tenant_access_token. Компенсация: проверенная проактивная отправка в группу Фазы 1 на сохраненном openConversationId (DingTalk, canColdSend становится true); Feishu ничего не нужно.
  3. Планировщик привязан к сессии, а не к демону. Cron умирает при dispose() во время 30-минутного idle reaping. Компенсация: планировщик, принадлежащий шлюзу (§6.2) — долгоживущий, переживает reaping, единственный владелец cron (OD-8).
  4. Память глобальна для рабочего пространства, а не для каждого канала. Компенсация: один процесс на канал (ноль кода) или область channel Фазы 2 (OD-10).
  5. Множественная идентификация / настоящий мульти-тенантинг выходят за рамки (Фаза 3). Моделируется как мульти-процессность в Фазах 0–2.

Риски и меры по их снижению

#РискКритичностьМеры по снижению
R1Вызовы инструментов в стеке каналов автоматически одобряются на пути Phase-0 AcpBridge (AcpBridge.ts:108-118) — скомпрометированный канал может запускать любой инструмент без проверок.ВысокаяЗапланированный на Phase-1 переход на демон включает медиатор; до этого ограничить набор инструментов + доверенный хост.
R2Утечка единого глобального токена демона предоставляет полный доступ к рабочему пространству (HTTP-путь демона; путь AcpBridge не использует токен).ВысокаяLoopback по умолчанию + проверка bearer-токена; --require-auth для не-loopback (OD-12); доверенный хост; ротация при перезапуске; защита деструктивных инструментов за consensus после интеграции.
R3Значение по умолчанию dispatchMode 'steer' отменяет текущую работу при получении сообщения от любого участника (в JSDoc было указано 'collect', теперь исправлено на 'steer', types.ts:42).ВысокаяДля групп с тегами установлено 'followup'; JSDoc согласован (OD-5).
R4Отсутствие атрибуции отправителя → агент путает говорящих.ВысокаяИнъекция [senderName] в Phase-0 для ходов в группе (+ alreadyPrefixed, OD-6).
R5Проактивные действия в “холодной” группе DingTalk или при истекшем вебхуке завершаются без вывода ошибок (:137-141).СредняяПроверенная проактивная отправка в группу в Phase-1 на основе сохраненного openConversationId; canColdSend с явным выводом ошибок; отображение деградаций.
R6Cron/уведомления перестают работать при очистке сессии (30 мин, run-qwen-serve.ts:94); также требуется исходящий путь (R5).СредняяПланировщик, принадлежащий шлюзу (§6.2); OD-8 — шлюз единоличного владельца.
R7requireMention = true → сообщения в группе без упоминания молча отбрасываются (GroupGate.ts:51-52).Низкая/СредняяОставить по умолчанию; задокументировать; опциональная подсказка в первом сообщении.
R8Общая память рабочего пространства приводит к перекрестному загрязнению данных совместно размещенных групп.СредняяОдин процесс на канал или область channel в Phase-2 (OD-10).
R9Ограничение частоты запросов действует на clientId/IP, а не на пользователя (путь демона); путь AcpBridge не имеет ограничений.НизкаяПриемлемо для single-tenant; учет на пользователя — в Phase 3.
R10Набор голосующих для консенсуса снимается в момент запроса; сегодня участники канала не являются отдельными clientId.НизкаяOD-3: first-responder в Phase 1; решить проблему маппинга senderId→голос до внедрения консенсуса.
R11DingTalk SDK никогда не обновляет токен доступа (~2 ч), если сокет не закрывается — проактивные действия/эмоции/медиа завершаются без ошибок.ВысокаяtokenManager принадлежит проактивной функции, обновление через эндпоинт v1.0 oauth2/accessToken (§6.2, проверено).
R12Проактивный вызов DaemonChannelBridge.prompt() во время хода пользователя вызовет ошибку Prompt already in flight (:257-261).ВысокаяdispatchProactive сериализует через sessionQueues и ожидает activePrompts перед bridge.prompt() — защита от выброса структурно недостижима (Fix #1, §6.2).
R13Ложноположительное срабатывание оценки бюджета может отклонить легитимный запрос пользователя.СредняяОценки только WARN; жесткое отклонение только при реальном использовании демона (Fix #6, §6.4).
R14Очередь followup ошибочно приписывает вызовы инструментов последнему добавленному в очередь отправителю.СредняяПередавать senderId в поставленном в очередь ходе; аудит читает выполняемый ход (Fix #7, §6.4).

9. Принятые решения

Все открытые решения v1 ниже разрешены с указанием выбранного ответа. Единственными по-настоящему открытыми вопросами остаются детали API DingTalk с низкой степенью уверенности в рамках OD-7, указанные в последней строке.

IDВопросРешение
OD-1Перенести хостинг каналов в qwen serve для Phase 1+ или остаться на AcpBridge?ПРИНЯТО — Мигрировать. Phase 0 поставляется на AcpBridge; Phase 1+ хостит каналы в qwen serve через DaemonChannelBridge / runner каналов демона, наследуя FIFO promptQueue, MultiClientPermissionMediator, eventBus, /workspace/memory и rate-limit. Phase 0 добавляет путь подключения (или --daemon <url>), чтобы переключение было шагом конфигурации. Планировщик шлюза (§6.2) не зависит от миграции. Больше не является блок-фактором — архитектура утверждена.
OD-2Единица развертывания = один процесс на рабочее пространство/канал?ПРИНЯТО — Да. Один процесс на рабочее пространство/канал: память и изоляция секретов для каждого канала, ограничение радиуса поражения при утечке единого глобального токена. Совместное размещение нескольких каналов — задача для Phase-3 (требуется область channel + governor).
OD-3Политика разрешений для многопользовательского тега (один канал = один clientId демона)?ПРИНЯТО — Phase 1: first-responder с единым clientId на уровне канала (любой разрешенный участник отвечает; атрибуция с гранулярностью канала; нет маппинга senderId→clientId). Phase 2: consensus/designated после появления реестра senderId→clientId + жизненного цикла (очистка, границы refcount). Автоматический запрет инструментов высокого риска при проактивных ходах.
OD-4/clear//status с областью действия в треде работают на весь канал.ПРИНЯТО — в общей группе (треде) /clear требует confirm и ограничен config.allowedUsers, если задано (дефисный /clear-channel не парсится; шлюз владельца для каждого участника отложен до модели идентификации, OD-3/OD-11); /status остается read-only для общей сессии.
OD-5Несоответствие значения по умолчанию dispatchMode (JSDoc 'collect' vs runtime 'steer').ПРИНЯТО — Исправить JSDoc в types.ts:42 на 'steer' (соответствует runtime); профиль группы с тегами явно устанавливает dispatchMode: 'followup'.
OD-6Формат маркера отправителя + двойной префикс в collect.ПРИНЯТО — Префикс [senderName] для каждого хода, БЕЗ шлюза instructedSessions, плюс ОДНО новое опциональное поле EnvelopealreadyPrefixed (types.ts), чтобы синтетический повторный вход в режиме collect пропускал повторное добавление префикса. (Исправляет утверждение v1 “нет новых полей”.)
OD-7Проактивная отправка DingTalk: эндпоинт/разрешение, эквивалентность openConversationId, обновление токена.ПРИНЯТО с проверенными фактами (§6.2/§6.5): эндпоинт POST https://api.dingtalk.com/v1.0/robot/groupMessages/send (высокая); тело { robotCode=config.clientId, openConversationId, msgKey:'sampleMarkdown', msgParam:<JSON string {title,text}> } (высокая); заголовок авторизации x-acs-dingtalk-access-token с токеном v1.0 oauth2/accessToken, TTL ~7200 с, кэшируется и обновляется tokenManager, принадлежащим функции (высокая); сохранять openConversationId в ~/.qwen/channels/dingtalk-groups.json; callback conversationIdopenConversationId (средняя; откат к API конвертации chatId→openConversationId при invalid.openConversationId). Оставшиеся открытыми (низкая уверенность): точный код точки разрешения/отображаемое имя; дословное официальное предложение об эквивалентности; применяется ли троттлинг 20/мин к groupMessages/send.
OD-8Двойное срабатывание Cron между планировщиками шлюза и сессии.ПРИНЯТО — Планировщик шлюза является ЕДИНСТВЕННЫМ владельцем Cron. Сессия, размещенная в канале (с тегами), не запускает свой внутри-сессионный Session cron; она узнает, что является сессией с тегами, через флаг isTagSession, передаваемый от хоста канала при создании сессии (набор опций DaemonChannelSessionFactory для Phase 1+; опция запуска --acp для Phase 0), что пропускает startCronScheduler() (Session.ts:667-668). Два хранилища cron находятся на непересекающихся путях (шлюз ~/.qwen/channels/cron.json против сессии ~/.qwen/tmp/<hash>/scheduled_tasks.json), поэтому единственный риск коллизии — запуск обоих планировщиков для одних и тех же задач — устранен шлюзом.
OD-9Область действия бюджета токенов, источник истины, окно.ПРИНЯТО — Сводка “org” для каждого процесса + окна для каждого канала, побеждает самое строгое, фиксированное дневное окно. v1 оценивает токены на стороне канала (рекомендательно, только WARN — никогда не делает жестких отказов, Fix #6) и читает путь использования демона для точного списания (и жесткого отказа) после размещения на демоне.
OD-10Пространство имен памяти для каждой комнаты + права на запись.ПРИНЯТО — Добавить область channel (+channelKey) в writeContextFile.ts; channel-base получает запись/чтение через callback CLI-слоя, внедряемый через ChannelBaseOptions (readChannelMemory/writeChannelMemory) — НЕТ зависимости channel-base → core. Глобальное расположение для пользователя ~/.qwen/channels/memory/. Агент добавляет данные через интент save_memory; начальное чтение повторно использует шлюз instructedSessions.
OD-11Модель идентификации человека + долговечность аудита.ПРИНЯТО — senderName носит только рекомендательный характер; clientId остается единственным субъектом безопасности. Атрибуция по мере возможностей передается с выполняемым ходом (Fix #7); кольцо аудита FIFO 512 в памяти + файл последующих действий ~/.qwen только для добавления.
OD-12Усиление защиты токена для развертываний с демоном не на loopback.ПРИНЯТО — Требовать --require-auth + токен для любого развертывания с демоном не на loopback. Только loopback — только для разработки; --require-auth — задокументированная позиция по умолчанию (run-qwen-serve.ts уже требует токен для не-loopback).
ОТКРЫТО (единственный оставшийся)Детали API DingTalk с низкой степенью уверенности в рамках OD-7.ВСЕ ЕЩЕ ОТКРЫТО — проверить в консоли / по актуальной документации перед написанием кода: (1) точный код точки разрешения/отображаемое имя для “проактивной отправки сообщения в группу” (низкая); (2) авторитетное официальное предложение, приравнивающее callback conversationId к openConversationId для стандартного робота не cool-app (средняя; гарантированный документацией путь — это API конвертации chatId→openConversationId); (3) применяется ли ограничение “20 сообщений/минуту → ~10-минутный троттлинг” дословно к groupMessages/send (низкая/средняя — задокументировано для роботов с кастомным вебхуком, не подтверждено на странице отправки orgapp).

10. Риски и их минимизация

См. сводную таблицу в §8. Критические риски в порядке приоритета:

  1. R1 — автоматическое одобрение на пути канала Phase-0. Пока запланированная в Phase-1 миграция демона не внедрит опосредованный транспорт, агент, работающий в канале, выполняет любой инструмент без проверок. Это самый критичный пробел в безопасности; минимизируется консервативным набором инструментов + доверенным хостом до Phase 1.
  2. R12 — исключение при наложении проактивных запросов. Вызов DaemonChannelBridge.prompt() во время хода пользователя выбрасывает Prompt already in flight (:257-261). Закрывается сериализацией через sessionQueues (Fix #1) — центральным элементом §6.2.
  3. R11 — истечение срока действия токена DingTalk. Сбой по принципу “работает в демо, умирает через 2 часа”. Проактивный функционал владеет tokenManager (проверенный эндпоинт v1.0, TTL ~7200 с) до того, как будет выпущен любой долгоживущий функционал.
  4. R5 — тихий сбой в “холодных” группах DingTalk. Проактивный вывод в неактивные группы невозможен без проверенного пути отправки; canColdSend генерирует явную ошибку вместо молчаливого игнорирования.
  5. R3 — отмена steer в группах. Случайный DoS для нескольких пользователей при стандартных настройках рантайма; профиль тега устанавливает followup.
  6. R13/R14 — ложные срабатывания бюджета и неверное присвоение. Оценки выдают только WARN (Fix #6); присвоение переносится вместе с выполняемым ходом (Fix #7).
  7. R8 — перекрестное загрязнение общей памяти. Один процесс на канал — это мера без написания кода; скоуп channel — это решение для совместного размещения.

Каждый риск привязан к фазе: R1/R3/R4 относятся к Phase 0–1, R5/R6/R11/R12 — к Phase 1, R8/R13/R14 и риски аудита/бюджета — к Phase 2.


11. Приложение: Индекс файлов и символов

Channel base (packages/channels/base/src/)

  • SessionRouter.tsroutingKey() (:44-60, тред :53, одиночный :55, пользователь :58), скоуп по умолчанию 'user' (:25), setChannelScope() (:40-42), resolve() (:72-92), getTarget() (:94), persist()/restoreSessions() (:168-244), PersistedEntry (:5-9).
  • ChannelBase.tshandleInbound() (:238-471), построение промпта (:316-347), вызов bridge.prompt() (:425), гейты (:240-252), разрешение dispatchMode (:353-354), steer (:371-379), collect (:361-370,445-463), followup (:381-383,394-470), activePrompts (:32-35,356), sessionQueues (:394,466), абстрактный sendMessage() (:81), registerCommand() (:141-143), конструктор роутера (:62-64), ChannelBaseOptions (:9-22,46), /clear//status (:147-217).
  • AcpBridge.ts — запуск --acp (:53-70), newSession(cwd) (:131), prompt() (:147-180), авто-одобрение requestPermission (:108-118), AcpBridgeOptions (:17-21).
  • DaemonChannelBridge.tsnewSession/loadSession sessionScope 'thread' (:229,240), пакет опций фабрики сессий (:226-241), гард activePrompts / выбрасывает Prompt already in flight (:257-261), cancelSession (:332), respondToPermission (:346-374), события разрешений (:557-633).
  • GroupGate.tsrequireMention по умолчанию true (:49), членство (:42), гейтинг упоминаний (:51-52), цепочка fallback (:48), политика по умолчанию 'disabled' (:13).
  • SenderGate.tscheck() + pairing (:42).
  • types.tsGroupConfig (:10-13), ChannelConfig (:27-51), approvalMode (:36), JSDoc для dispatchMode исправлен на 'steer' (:42), senderName (:69), новое поле alreadyPrefixed, isGroup (:75), SessionTarget (:88-93).

DingTalk (packages/channels/dingtalk/src/)

  • DingtalkAdapter.ts — карта webhooks (:84), sendMessage() (:134-170, возврат без webhook :137-141), кэш webhook (:516-517), getAccessToken() (:172-174), emotionApi() (:188-207, robotCode :184, openConversationId :197, антипаттерн пустого catch :214-216), media robotCode (:435), inbound conversationId (:506), удаление упоминания (:527-529), isMentioned (:520), senderName (:544), extractQuotedContext() (:272-298), chatId (:534), нет threadId (:541-551).
  • proactive.ts (новый) — sendGroupMessage() в POST /v1.0/robot/groupMessages/send (robotCode+openConversationId+msgKey:'sampleMarkdown'+msgParam JSON-строка), tokenManager (v1.0 oauth2/accessToken, TTL ~7200 с, таймер + обновление по 401), fallback конвертации chatId→openConversationId.
  • markdown.ts — пропуск таблиц, splitChunks(), CHUNK_LIMIT=3800 (≤ бюджета ~5000 символов для sampleMarkdown), extractTitle(), normalizeDingTalkMarkdown().
  • media.ts — заголовок downloadMedia (:39), тело :42.
  • SDK: client.mjs gettoken (:85-87), переподключение (:157-163), разделение event/callback (:14-19,35-37,58-61,241-257); constants.d.ts sessionWebhookExpiredTime (:13), robotCode (:19), TOPIC_CARD (:4).

Feishu (packages/channels/feishu/src/)

  • FeishuAdapter.ts — проактивный sendMessage() (:622-676, эндпоинт :651; canColdSend = true), refreshToken() (:581-620), режимы connect() (:146-176), updateCard() (:742-792), дедупликация ingest (:1633-1870).
  • markdown.ts — содержимое карточки schema-v2 (:69-189), splitChunks() (:198-256).

Core (packages/core/src/)

  • memory/writeContextFile.tsWriteContextFileScope (:80, +'channel'), WriteContextFileOptions (:83-97, +channelKey), resolveContextFilePath() (:223-240, +ветка channel + параметр channelKey), мьютекс на файл (:48-57,159-162), гард абсолютного пути (:142-146), MAX_EXISTING_FILE_BYTES (:255), режим замены (:202-211).
  • utils/cronParser.tsparseCron/matches/nextFireTime (:104,141,168).
  • utils/cronTasksFile.tsDurableCronTask (:19-26), хешированный путь на проект (:1-9).
  • Session.ts — объявления полей cronQueue/cronProcessing (:667-668), startCronScheduler() (:758, пропускается для тег-сессий согласно OD-8), очистка cron в dispose() (:790-812), #recordPromptTokenCount() (:2078-2087), setNotificationCallback() (:2638-2668), isIdle() (:777).

Serve / daemon (packages/cli/src/serve/, packages/acp-bridge/src/)

  • bridge.ts — FIFO promptQueue на каждый SessionEntry (:232,2855,3082), publishWorkspaceEvent (:3610,3649-3675).
  • eventBus.ts — свободный формат BridgeEvent.data (:51), originatorClientId (:60), пороги гистерезиса (:101-103), кольцевой буфер повтора (:92).
  • permissionMediator.ts — четыре политики + кворум консенсуса (:348,621-637).
  • permission-audit.ts — FIFO PermissionAuditRing на 512 (:128-172), объединение закрытых записей (:57-104), документация заголовка, предвосхищающая GET-поверхность (:22-25).
  • rate-limit.ts — токен-бакеты на каждый (clientId|ip); X-Qwen-Client-Id (:110).
  • auth.ts — глобальный bearer-токен (:259-266), строгий createMutationGate (:356).
  • workspace-memory.ts — скоупы workspace|global (:118-125), строгая аутентификация для mutate (:114), лимит на запись MAX_MEMORY_CONTENT_BYTES (:79), жесткая передача projectRoot (:185-190).

CLI channel commands (packages/cli/src/commands/channel/)

  • start.tsstartCommand (:479-499), создание AcpBridge (:213,268,356,435), setChannelScope (:361-362), restoreSessions (:275,444), sessionsPath() (:56-58), checkDuplicateInstance() (:170-179), обработчик отключения (:241,403); путь подключения демона Phase 1+; инъекция на уровне CLI для readChannelMemory/writeChannelMemory.
  • config-utils.tsparseChannelConfig() (:81-100, sessionScope по умолчанию :91-92, approvalMode :94, groupPolicy :98), resolveEnvVars() (:6-18).
  • channel-registry.tsensureBuiltins() (:6-32), типы каналов (:10-14).
Last updated on