Skip to Content
Руководство для разработчиковDaemonТипизированная схема событий демона v1

Типизированная схема событий демона v1

Обзор

Каждый SSE-фрейм, генерируемый демоном на эндпоинте GET /session/:id/events, имеет структуру { id, v, type, data, originatorClientId?, _meta? }. v: 1 — это текущая версия EVENT_SCHEMA_VERSION. Поле type берется из закрытого, зафиксированного по версии набора DAEMON_KNOWN_EVENT_TYPE_VALUES в packages/sdk-typescript/src/daemon/events.ts; текущий набор содержит 47 известных типов событий. Поле _meta в обертке проставляется на границе записи SSE функцией formatSseFrame() в packages/cli/src/serve/routes/sse-events.ts; см. Метаданные уровня обертки.

SDK предоставляет метод asKnownDaemonEvent(evt). Он возвращает дискриминированный объект KnownDaemonEvent для известных типов событий и undefined для остальных. Таким образом, потребители SDK могут обеспечивать прямую совместимость без необходимости синхронного обновления SDK, когда более новая версия демона добавляет новый тип события; редьюсер сессии записывает такие события как unrecognizedKnownEventCount.

Сетевой формат описан в ../qwen-serve-protocol.md. На этой странице представлен контракт полезной нагрузки для каждого события.

Ответственность

  • Предоставляет единый источник истины для словаря событий (DAEMON_KNOWN_EVENT_TYPE_VALUES).
  • Предоставляет типизированную обертку для каждого типа события (DaemonEventEnvelope<TType, TData>).
  • Предоставляет чистые редьюсеры (reduceDaemonSessionEvent, reduceDaemonAuthEvent), которые проецируют поток событий в состояние представления SDK.
  • Транслирует тег возможности typed_event_schema в качестве информационного сигнала. Если тег отсутствует, asKnownDaemonEvent все равно возвращает unknown.

Словарь событий (47 известных типов)

Сгруппированы по доменам.

Основная сессия

ТипНаправлениеТриггерКлючевые поля payload
session_updateS->CЛюбое уведомление ACP sessionUpdate: текст агента, размышление, вызов инструмента или планsessionUpdate: string, content?: ... (непрозрачная структура ACP)
session_metadata_updatedS->CPATCH /session/:id/metadatasessionId, displayName?
session_diedS->C terminalchannel.exitedsessionId, reason, exitCode? | null, signalCode? | null
session_closedS->C terminalDELETE /session/:id или программное закрытиеsessionId, reason: 'client_close' | string, closedBy?
session_snapshotS->C syntheticСнимочный фрейм после подключения / воспроизведения SSEsessionId, currentModelId: string | null, currentApprovalMode: string | null

Синтетические фреймы уровня подписчика

ТипТриггерПримечания
client_evictedПереполнение очереди EventBus для конкретного подписчика. Нет idreason: 'queue_overflow' | 'queue_bytes_overflow' | string, droppedAfter?: number, queueSize?: number, maxQueued?: number, queuedBytes?: number, maxQueuedBytes?: number, eventBytes?: number; завершающее событие только для текущего подписчика, при этом сессия остается активной.
slow_client_warningОтставание по живым фреймам или живым сериализованным байтам >= 75%; принудительно отправляется и не имеет idqueueSize, maxQueued, lastEventId, queuedBytes?, maxQueuedBytes?, threshold?: 'frames' | 'bytes' | 'frames_and_bytes'; повторно активируется после того, как показатели по фреймам и байтам падают ниже 37.5%.
stream_errorSubscriberLimitExceededError или другая ошибка потока маршрутаerror: string; завершающее событие для подписки.
state_resync_requiredsubscribe({lastEventId}) обнаруживает, что кольцевой буфер демона больше не содержит [lastEventId+1, earliestInRing-1], или курсор клиента относится к предыдущей эпохе шины. Принудительно отправляется до оставшихся фреймов воспроизведения и не имеет id.reason: 'ring_evicted' | 'epoch_reset' | string, lastDeliveredId: number, earliestAvailableId: number. Это сигнал восстановления, а не завершающее событие: поток SSE остается открытым, и воспроизведение + живые фреймы продолжаются. Редьюсер SDK устанавливает awaitingResync = true и пропускает дельты, пока вызывающий код не выполнит сброс с помощью loadSession.
replay_completeСигнальное событие без id, генерируемое после завершения цикла воспроизведения Last-Event-ID, как для чистого воспроизведения, так и для путей с выселением из кольца, даже если data.replayedCount === 0. Нет idreplayedCount: number; позволяет потребителям детерминированно скрывать UI синхронизации без использования таймаута.

Разрешения (F3 + base)

ТипНаправлениеТриггерКлючевые поля payload
permission_requestS->CАгент вызывает requestPermissionrequestId, sessionId, toolCall, options[]; обертка проставляет originatorClientId от инициатора промпта.
permission_resolvedS->CМедиатор принял решениеrequestId, outcome (ACP PermissionOutcome)
permission_already_resolvedS->CГолос поступает после того, как запрос уже был решенrequestId, sessionId, outcome
permission_partial_voteS->CПолитика consensus фиксирует неокончательный голосrequestId, sessionId, votesReceived, votesNeeded (>= 1), quorum, optionTallies: Record<string, number>, originatorClientId?
permission_forbiddenS->CПолитика отклоняет голосrequestId, sessionId, clientId?, reason: 'designated_mismatch' | 'remote_not_allowed', originatorClientId?; анонимные голосующие не указывают clientId.

Модели

ТипНаправлениеPayload
model_switchedS->CsessionId, modelId
model_switch_failedS->CsessionId, requestedModelId, error: string

Ограничения MCP (PR 14b + F2)

ТипНаправлениеPayload
mcp_budget_warningS->CliveCount, reservedCount, budget, thresholdRatio: 0.75, mode: 'warn' | 'enforce', scope?: 'workspace' | 'session'
mcp_child_refused_batchS->CrefusedServers: [{ name, transport, reason: 'budget_exhausted' }], budget, liveCount, reservedCount, mode: 'enforce', scope?: 'workspace' | 'session'
mcp_server_restartedS->CserverName, durationMs, entryIndex? для перезапусков пула с несколькими записями F2
mcp_server_restart_refusedS->CserverName, reason: 'budget_would_exceed' | 'in_flight' | 'disabled' | 'restart_failed', entryIndex?, details?. Четвертое значение, restart_failed, содержит информацию о базовой жесткой ошибке при перезапуске пула с несколькими записями. MCP_RESTART_REFUSED_REASONS отклоняет неизвестные причины; старый редьюсер SDK молча отбрасывает новые добавленные значения причин, поскольку parseDaemonEvent возвращает undefined. Отправляйте новую причину вместе с SDK, который её поддерживает.

Управление мутациями (Wave 4 PR 16+17)

ТипНаправлениеПолезная нагрузка
memory_changedS->CФайловая память: scope: 'workspace' | 'global', filePath, mode, bytesWritten; управляемая память: scope: 'managed', source, taskId, touchedScopes
agent_changedS->Cchange: 'created' | 'updated' | 'deleted', name, level: 'project' | 'user'
approval_mode_changedS->CsessionId, previous, next, persisted: boolean
tool_toggledS->CtoolName, enabled; влияет на следующий запуск дочернего процесса ACP и не изменяет уже запущенные сессии.
settings_changedS->CЗапись настроек рабочего пространства завершена. Полезная нагрузка открыта; потребителям следует обновить данные с помощью чтения после записи.
settings_reloadedS->CСервис рабочего пространства демона перечитал настройки. Полезная нагрузка открыта.
trust_change_requestedS->CworkspaceCwd, desiredState: 'trusted' | 'untrusted', reason?
workspace_initializedS->Cpath, action: 'created' | 'overwrote' | 'noop', originatorClientId?
github_setup_completedS->CreleaseTag, readmeUrl, secretsUrl?, workflows: [{path, status, sizeBytes?, error?}], gitignore: {path, status, added?, error?}

memory_changed также охватывает задачи управляемой памяти без сессии. Для таких полезных нагрузок scope равен "managed", source — это одно из значений "workspace_memory_remember", "workspace_memory_forget" или "workspace_memory_dream", taskId — это идентификатор задачи в очереди, а touchedScopes содержит список измененных областей управляемой памяти ("user" и/или "project"). Событие не генерируется, если задача remember/forget/dream завершается без изменения управляемой памяти.

Device flow аутентификации (PR 21)

Эти события привязаны к рабочему пространству (workspace), а не к сессии. Редьюсер сессии обрабатывает их как no-op; reduceDaemonAuthEvent проецирует их в состояние на уровне рабочего пространства.

ТипНаправлениеПолезная нагрузка
auth_device_flow_startedS->CdeviceFlowId, providerId, expiresAt
auth_device_flow_throttledS->CdeviceFlowId, intervalMs
auth_device_flow_authorizedS->CdeviceFlowId, providerId, expiresAt?, accountAlias?
auth_device_flow_failedS->CdeviceFlowId, errorKind, hint?
auth_device_flow_cancelledS->CdeviceFlowId

Мутации runtime MCP

ТипНаправлениеТриггерКлючевые поля полезной нагрузки
mcp_server_addedS->CСервер добавлен в runtime через POST /workspace/mcp/serversname, transport, replaced, shadowedSettings, toolCount, originatorClientId
mcp_server_removedS->CСервер удален в runtimename, wasShadowingSettings, originatorClientId

Жизненный цикл расширений

ТипНаправлениеТриггерКлючевые поля полезной нагрузки
extensions_changedS->CЗавершена фоновая установка/обновление расширения или изменен статусrefreshed, failed, status?: 'installed' | 'enabled' | 'disabled' | 'updated' | 'uninstalled' | 'failed', source?, name?, version?, error?

Инъекция сообщений в середине хода

ТипНаправлениеТриггерКлючевые поля полезной нагрузки
mid_turn_message_injectedS->CВеб-оболочка или удаленный клиент внедрил сообщения в выполняющийся ход через POST /session/:id/injectsessionId, messages: string[], originatorClientId?; потребители ДОЛЖНЫ сравнивать originatorClientId со своим собственным id перед дедупликацией.

Жизненный цикл хода / push-уведомления от ассистента

ТипНаправлениеТриггерКлючевые поля полезной нагрузки
prompt_cancelledS->CПромпт был отменен через явный маршрут cancelSession или из-за отключения SSE инициатораОболочка добавляет originatorClientId для отменяющего клиента. Это означает “запрошена отмена”, а не “отмена подтверждена”. Равноправные подписчики узнают, что промпт завершен.
turn_completeS->CХод успешно завершенsessionId, stopReason, promptId?. promptId связывает с неблокирующими ответами на промпт (202). SDK сопоставляет события SSE с исходным промптом с его помощью.
turn_errorS->CСбой ходаsessionId, message, code?, promptId?; тот же механизм корреляции promptId.
session_rewoundS->CPOST /session/:id/rewind выполнен успешноsessionId, promptId, targetTurnIndex, filesChanged[], filesFailed[], originatorClientId?
session_branchedS->CPOST /session/:id/branch создал ветку из существующей сессииsourceSessionId, newSessionId, displayName, originatorClientId?
followup_suggestionS->CДочерний процесс ACP сгенерировал follow-up предложения в виде ghost-текста после end_turn, которые были пересланы через SSE для каждой сессииsessionId, suggestion, promptId; по каналу передаются только предложения, у которых getFilterReason()===null. Клиенты отображают их как ghost-текст в плейсхолдере ввода и инвалидируют их при следующем sendPrompt.
user_shell_commandS->CПользователь запустил shell-команду через POST /session/:id/shell; событие рассылается другим подписчикам в той же сессииsessionId, command, shellId, originatorClientId?. Типизированного интерфейса DaemonXxxData пока нет; asKnownDaemonEvent возвращает undefined, и нормализатор UI разбирает его ad hoc.
user_shell_resultS->CРезультат выполнения вышеуказанной shell-командыsessionId, shellId, exitCode, output, aborted. То же замечание о разборе ad hoc, что и для user_shell_command.

Архитектура

АспектИсточникПримечания
EVENT_SCHEMA_VERSION = 1packages/acp-bridge/src/eventBus.tsОтправляется в каждом фрейме.
DAEMON_KNOWN_EVENT_TYPE_VALUESpackages/sdk-typescript/src/daemon/events.tsЗакрытый список из 47 типов.
DaemonEventEnvelope<TType, TData>events.tsОбщая оболочка.
DaemonKnownEventTypeevents.tstypeof DAEMON_KNOWN_EVENT_TYPE_VALUES[number].
Типы полезных нагрузок для каждого событияevents.tsБольшинство типов событий имеют интерфейс DaemonXxxData; user_shell_* в настоящее время разбирается нормализатором UI ad hoc.
asKnownDaemonEvent(evt)events.tsВозвращает KnownDaemonEvent | undefined.
reduceDaemonSessionEvent(state, evt)events.tsПроецируется в DaemonSessionViewState.
reduceDaemonAuthEvent(state, evt)events.tsПроецируется в DaemonAuthState.
isWorkspaceScopedBudgetEvent(evt)events.tsОбнаруживает F2 scope: 'workspace'.

DaemonSessionViewState

reduceDaemonSessionEvent заполняет это состояние представления. Адаптер CLI TUI, DaemonChannelBridge и VS Code IDE потребляют его. Ключевые поля:

  • alive: boolean — становится false после терминального фрейма (session_died, session_closed, client_evicted, stream_error).
  • currentModelId?: string — из model_switched.
  • displayName?: string — из session_metadata_updated.
  • pendingPermissions: Record<string, DaemonPermissionRequestData> — открытые запросы, сгруппированные по requestId; очищается с помощью permission_resolved / permission_already_resolved.
  • lastSessionUpdate?: DaemonSessionUpdateData — последний session_update.
  • lastModelSwitchFailure?: DaemonModelSwitchFailedData — из model_switch_failed.
  • terminalEvent? — сырое терминальное событие.
  • streamError?: DaemonStreamErrorData — полезная нагрузка последнего stream_error.
  • unrecognizedKnownEventCount, lastUnrecognizedKnownEvent? — событие было распознано через asKnownDaemonEvent, но у редьюсера пока нет выделенного состояния для него.
  • droppedPermissionRequestCount, lastDroppedPermissionRequestId? — некорректный запрос разрешений не смог попасть в pending map.
  • unmatchedPermissionResolutionCount, lastUnmatchedPermissionResolutionId? — разрешение запроса не нашло соответствующего ожидающего запроса.
  • slowClientWarningCount, lastSlowClientWarning? — из slow_client_warning.
  • mcpBudgetWarningCount, lastMcpBudgetWarning? — из mcp_budget_warning.
  • mcpChildRefusedBatchCount, lastMcpChildRefusedBatch? — из mcp_child_refused_batch.
  • lastWorkspaceMutation?, lastWorkspaceMutationType? — из memory_changed / agent_changed.
  • approvalMode?, approvalModeChangedCount, lastApprovalModeChange? — из approval_mode_changed.
  • toolToggleCount, lastToolToggle? — из tool_toggled.
  • workspaceInitCount, lastWorkspaceInit? — из workspace_initialized.
  • mcpRestartCount, lastMcpRestart? — из mcp_server_restarted.
  • mcpRestartRefusedCount, lastMcpRestartRefused? — из mcp_server_restart_refused.
  • settings_changed / settings_reloaded — распознаются через asKnownDaemonEvent; редьюсер сессии не поддерживает выделенные поля состояния представления, и UI обычно обрабатывает их как сигналы обновления.
  • permissionVoteProgress: Record<string, DaemonPermissionPartialVoteData> — прогресс голосования по консенсусу.
  • forbiddenVotes: DaemonPermissionForbiddenData[], forbiddenVoteCount — записи о голосах, отклоненных политикой, ограничено 32.
  • awaitingResync: boolean — устанавливается через state_resync_required; очищается, когда потребитель сбрасывает состояние представления.
  • resyncRequiredCount, lastResyncRequired? — наблюдаемость ресинхронизации.
  • lastFollowupSuggestion?: DaemonFollowupSuggestionData — последнее follow-up предложение, отправленное демоном.
  • lastTurnComplete?: DaemonTurnCompleteData — последнее успешное завершение хода.
  • lastTurnError?: DaemonTurnErrorData — последняя ошибка хода.
  • rewindCount, lastRewind?, lastBranch? — последние события rewind / branch.

DaemonAuthState

Одна запись для каждого providerId, управляемая событиями auth_device_flow_*. Каждый flow предоставляет объект вида { deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError? }.

Поток

На стороне продюсера

На стороне потребителя (SDK)

Метаданные уровня оболочки

Помимо полезной нагрузки data каждого события, демон добавляет два поля на уровне оболочки (envelope).

_meta.serverTimestamp - часы демона

EventBus.publish() в packages/acp-bridge/src/eventBus.ts добавляет _meta.serverTimestamp, когда событие попадает в шину. Тип BridgeEvent включает _meta?: Record<string, unknown>, поэтому внутренние потребители демона видят _meta для каждого события, опубликованного в шине. formatSseFrame() в packages/cli/src/serve/routes/sse-events.ts предоставляет резервную метку времени только для синтетических фреймов (например, stream_error), которые обходят EventBus.publish.

{ "id": 47, "v": 1, "type": "session_update", "data": { ... }, "_meta": { "serverTimestamp": 1716287345123 } }

Слияние сохраняет любые существующие ключи _meta из входного события ({...input._meta, serverTimestamp: Date.now()}). Продюсеры могут прикреплять дополнительные ключи _meta на уровне оболочки; EventBus.publish объединяет их с меткой времени, а не перезаписывает.

Почему это важно: многоклиентские UI, которые отображают относительное время или сортируют блоки транскриптов, должны использовать серверное время вместо локальных часов каждого браузера/вкладки/телефона. Серверная метка времени обеспечивает согласованный порядок для всех клиентов.

Доступ в SDK: предпочтительно использовать event._meta?.serverTimestamp. Пути совместимости также могут проверять event.serverTimestamp или event.data._meta.serverTimestamp. Не путайте data._meta полезной нагрузки ACP с _meta оболочки демона.

originatorClientId

События, инициированные запросом с зарегистрированным X-Qwen-Client-Id, могут заполнять это поле. См. 08-session-lifecycle.md.

_meta вызова инструмента (provenance / serverId)

Это отдельно от _meta оболочки: полезные нагрузки ACP session/update могут содержать собственный _meta в event.data._meta. ToolCallEmitter (packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts) добавляет два поля при emitStart, emitResult и emitError:

ПолеТипПравило определения
provenance'builtin' | 'mcp' | 'subagent'ToolCallEmitter.resolveToolProvenance: subagentMeta имеет приоритет и возвращает subagent; имя инструмента, соответствующее mcp__<server>__<tool>, сопоставляется с mcp; всё остальное сопоставляется с builtin.
serverIdstring, только если provenance === 'mcp'Эвристически извлекается из mcp__<serverId>__<tool>.

Существующее отображаемое имя _meta.toolName сохраняется. UI использует эти поля для отрисовки бейджей builtin / MCP server / subagent без повторного парсинга имени инструмента.

Поведение редьюсера SDK

reduceDaemonSessionEvent(state, evt) в packages/sdk-typescript/src/daemon/events.ts проецирует поток в DaemonSessionViewState. Поля, связанные с ресинком:

  • awaitingResync: boolean — устанавливается state_resync_required; вызывающая сторона очищает его, обычно после того как POST /session/:id/load сбрасывает состояние представления.
  • resyncRequiredCount: number — счетчик для наблюдаемости.
  • lastResyncRequired?: DaemonStateResyncRequiredData — последняя полезная нагрузка.

Пока awaitingResync = true, редьюсер пропускает применение дельт и разрешает только закрытый набор RESYNC_PASSTHROUGH_TYPES:

Тип пропусканияПочему он всё ещё применяется во время ресинка
state_resync_requiredРедкий второй ресинк должен обновлять lastResyncRequired / resyncRequiredCount.
session_diedТерминальный сигнал потока должен оставаться видимым во время ресинка.
session_closedАналогично.
client_evictedАналогично.
stream_errorАналогично.
session_snapshotАвторитативный фрейм с полным состоянием; безопасно применять во время ресинка.

lastEventId по-прежнему монотонно увеличивается через advanceLastEventId(base) во время ресинка. После того как вызывающая сторона сбрасывает и очищает awaitingResync, последующие дельты выравниваются по правильному курсору.

reduceDaemonAuthEvent концептуально проецирует события device-flow в записи состояния аутентификации на уровне воркспейса вида {deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError?}. В коде редьюсер хранит status, errorKind, hint, intervalMs, lastSeenEventId, authorizedExpiresAt и accountAlias в DaemonDeviceFlowReducerState; сами полезные нагрузки событий демона остаются в формах для каждого события, перечисленных выше.

Состояние и прямая совместимость

  • Добавьте известный тип события, добавив его в DAEMON_KNOWN_EVENT_TYPE_VALUES. Старые SDK возвращают undefined для нераспознанных типов событий через путь fallback и увеличивают unrecognizedKnownEventCount; новые SDK полагаются на discriminated union.
  • Добавление опциональных полей в существующую полезную нагрузку безопасно, так как полезные нагрузки открыты ({ [key: string]: unknown }).
  • Изменение формы существующей полезной нагрузки является breaking change и должно увеличивать EVENT_SCHEMA_VERSION, а также анонсировать совместимый тег возможности, такой как caps.features.typed_event_schema_v2.
  • id монотонен в рамках сессии. Синтетические фреймы на уровне подписчика (client_evicted, slow_client_warning, stream_error, state_resync_required, replay_complete, session_snapshot) намеренно не имеют id, чтобы другие подписчики не видели пропусков.
  • originatorClientId находится в envelope, а не в data. Полезные нагрузки F3 partial-vote / forbidden также объединяют его в data через mergeOriginator, чтобы потребителям состояния представления не нужно было сохранять envelope.

Зависимости

Конфигурация

  • Всегда анонсируются: typed_event_schema, mcp_guardrail_events и permission_mediation (с поддерживаемыми режимами политик).
  • Ни одна переменная окружения или флаг напрямую не управляет самой схемой. QWEN_SERVE_NO_MCP_POOL=1 изменяет scope событий MCP с 'workspace' на отсутствующий или 'session'.

Важные замечания и известные ограничения

  • Шесть типов синтетических фреймов намеренно не имеют id; код SDK не должен предполагать, что каждое событие имеет id.
  • permission_partial_vote появляется только в consensus. permission_forbidden появляется в designated, consensus и local-only, но не в first-responder.
  • mcp_child_refused_batch появляется только в mode: 'enforce'; режим warn никогда не отклоняет.
  • События auth_device_flow_* не привязаны к сессии. При потреблении через DaemonSessionClient используйте для них reduceDaemonAuthEvent, а не редьюсер сессии.

Ссылки

  • packages/sdk-typescript/src/daemon/events.ts
  • packages/acp-bridge/src/eventBus.ts (EVENT_SCHEMA_VERSION)
  • packages/cli/src/serve/capabilities.ts (typed_event_schema, mcp_guardrail_events, permission_mediation)
  • Ссылка на протокол: ../qwen-serve-protocol.md
Last updated on