qwen-code/docs/developers/daemon/11-capabilities-versioning.md
jinye f3ad4fcffb
feat(serve): page large text files by byte cursor (#8002)
* fix(serve): allow bounded reads of large text files

* fix(serve): bound large-text reads by scan cost, not by which knob was set

Follow-up to the bounded large-text read path. Three changes:

Gate on any explicit window argument, not on `limit`. Gating on `limit`
had the cost model backwards in both directions: `{ line: 900_000_000,
limit: 20 }` was admitted despite walking the whole file, while
`{ maxBytes: 4096 }` — satisfiable from the first 4 KiB — was refused. A
read with no window argument at all still fails, since a caller that
believes it holds the whole file may write it back truncated.

Add MAX_TEXT_SCAN_BYTES (8 MiB). MAX_READ_BYTES caps what a read
returns; nothing capped what it cost. Line offsets are resolved by
scanning from byte 0, so a query param could turn into an
uninterruptible multi-second scan of an arbitrarily large file — and on
Windows hold a read handle for that span, blocking renames and deletes.
Past the budget the read is refused with `file_too_large` pointing at
readBytes, which reaches any offset in O(1).

Tolerate appends on streamed windows. Requiring whole-file size/mtime
stability after reading a prefix rejected reads whose returned bytes
were still valid, and the case it rejected — tailing a live log — is the
one this path exists for. Streamed windows now assert inode identity
plus "did not shrink"; truncation and replacement are still rejected.

Also: non-UTF-8 large text now returns `binary_file` rather than
`file_too_large`, so a client retrying on 413 with a smaller window
can't loop forever; and `readFileWithLineAndLimit` throws instead of
silently ignoring a caller-supplied `fileHandle` on the by-path
fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(core): thread the descriptor instead of forking text-read helpers

PR #7947 pinned large-text reads to one inode by threading a caller-owned
FileHandle into readTextRange as an optional field, plus a second field,
forceStreaming, to suppress the buffering fast path. Two optional fields
produced four combinations: one meaningful, one used by a single test, one
unreachable, and — in readFileWithLineAndLimit — one that silently fell
through to a by-path read, defeating the reason the caller opened a handle.

Unify the two encoding detectors. detectFileEncoding now takes a path or a
borrowed handle, so detectFileHandleEncoding is deleted along with the
message discrepancy between them: an encoding iconv-lite cannot load now
raises LargeNonUtf8TextError naming that encoding rather than deferring to
the decoder's generic invalid-utf8 variant. Both still refuse the file, and
the Serve boundary maps both to binary_file.

Split the reader into readTextRange (path) and readTextRangeFromHandle
(always streams, both byte bounds required). The unreachable combination and
its untested readFileHandleBuffer are gone, and with no fileHandle parameter
left for readFileWithLineAndLimit to ignore, the RangeError guarding that
fallthrough is deleted too — the trap can no longer be expressed.

CoreReadTextFileHandleRequest drops its required stats field. Nothing
downstream read it, and because the ACP request type it extends permits
extra properties, TypeScript accepted the dead argument silently.

readFileHandleChunks becomes chunksFromHandle(fh, from) — the one seam
byte-cursor text paging needs.

No observable change at the Serve boundary: its 222 tests pass unmodified.
Two fileSystemService tests were deleted rather than repaired; they asserted
the arguments readFileWithLineAndLimit received, which is nothing once the
handle path stops calling it. Their coverage lives in read-text-range.test.ts
against real files and in workspace-file-system.test.ts at the real boundary.

258 production lines in core, net -71 overall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(core): make CoreReadTextFileHandleRequest standalone

Self-audit follow-up to f55c867a. Two fields survived the reshape that the
handle path never reads:

- `stats` was documented as required ("must pass the Stats captured from that
  handle") and nothing downstream read it. The handle path always streams, so
  it never needs a size to choose a strategy, and the encoding probe does its
  own fstat.
- `path` became dead once readTextRangeFromHandle replaced the path-plus-handle
  call. Errors are labelled with the path by the Serve boundary that owns it.

Neither was caught by the compiler: the ACP ReadTextFileRequest the type
derived from permits extra properties, so the CLI kept passing both silently.
That is the argument for declaring the type standalone rather than Omit-ing
four of six inherited fields and quietly re-admitting the rest.

Also record the second behaviour delta of the detector merge in the design
doc: detectFileEncoding catches I/O errors and falls back to 'utf-8', where
detectFileHandleEncoding let them propagate. The failure is not lost — a handle
that fails the 8 KiB probe fails the streaming read immediately after — but a
different call now reports it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(serve): page large text files by byte cursor

Line offsets address a byte stream, so `readText` resolves them by scanning
from byte 0. Paging a large log that way is O(n^2) across pages, and past
MAX_TEXT_SCAN_BYTES (8 MiB) a deep page is refused outright — agents had no
O(1) path short of dropping to GET /file/bytes and splitting lines themselves,
losing encoding handling, multibyte safety, and the binary_file refusal.

A response that leaves content behind now returns `hasMore`, and where a file
byte offset is derivable, an opaque `nextCursor`. Passing it back as `cursor`
resumes in O(1). Page 1 is an ordinary `limit` read, so clients never compute
byte offsets themselves, and a paging loop does not break when a file happens
to be small.

The cursor is unsigned base64url JSON carrying {off, size, dev, ino}, matching
encodeOrganizedCursor rather than the HMAC-signed transcript codec: the path is
re-resolved through the workspace boundary on every request, so a forged cursor
can only move the offset within a file the caller may already read — what
GET /file/bytes?offset= allows today. What the payload is for is staleness:
a replaced or truncated file yields hash_mismatch instead of bytes from the
wrong place, while an append leaves an outstanding cursor valid — the case the
feature exists for.

Every minted cursor points at the start of a line. When a single line exceeds
maxOutputBytes the reader emits a truncated prefix and skips to the next line
rather than resuming mid-line, because a mid-line cursor makes the following
page snap forward and silently drop the rest of that line at the seam. Windows
cut mid-line by a byte cap therefore report hasMore with no cursor, as do
non-UTF-8 snapshot reads whose decoded text is a UTF-8 re-encoding with no
mapping back to file offsets. That is why hasMore is a field rather than a
restatement of nextCursor.

Cursor reads branch before the size check, not by widening the window gate:
a cursor read of a file under MAX_READ_BYTES would otherwise land on the
snapshot path, which knows only line/limit, and silently return line 0.

Adds the workspace_file_read_cursor capability, per the convention that new
behavior gets a new tag, and retargets the scan-budget hint at cursor paging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(core): advance UTF-8 cursors after truncation

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(serve): clarify cursor bootstrap limits

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): raise daemon browser bundle budget

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(serve): cover ACP cursor dispatch and cursor binary_file mapping (#8002)

* fix(core): only set sawCrlf for emitted lines in cursor paging (#8002)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
2026-07-30 12:07:05 +00:00

15 KiB

Capabilities & Protocol Versioning

Overview

GET /capabilities is the daemon preflight endpoint. Every SDK client should read it before calling any other route so it can learn which protocol version the daemon speaks, which feature tags are enabled, and which workspace runtimes the daemon accepts. The contract:

  • There is one protocol version: v1. SERVE_PROTOCOL_VERSION = 'v1' and SUPPORTED_SERVE_PROTOCOL_VERSIONS = ['v1']. v1 is additive internally; breaking frame-shape changes are reserved for v2.
  • Each tag has a since version. Future v2 daemons can advertise both v1 and v2 tags.
  • Some tags are conditional. Tags listed in CONDITIONAL_SERVE_FEATURES are advertised only when the corresponding deployment toggle is enabled. Tag presence means the behavior exists.
  • Capability tag = behavior contract. Adding new behavior under an existing tag can silently break clients that preflighted the old tag. New behavior needs a new tag.

The complete registry lives in packages/cli/src/serve/capabilities.ts.

Responsibilities

  • Declare every feature the daemon may advertise.
  • Filter advertised features by protocol version and deployment toggles.
  • Expose getRegisteredServeFeatures() (all keys, unfiltered), getAdvertisedServeFeatures(version, toggles) (filtered), and getServeProtocolVersions() (envelope { current, supported }).
  • Preserve the invariant "tag present means behavior present". server.test.ts includes a test that every conditional tag advertises when its toggle is on; adding a conditional tag without a predicate fails that test.

Architecture

Capability envelope

/capabilities returns:

{
  v: 1,                    // CAPABILITIES_SCHEMA_VERSION
  mode: 'http-bridge',
  features: ServeFeature[],
  workspaceCwd: string,
  workspaces?: Array<{ id: string, cwd: string, primary: boolean, trusted: boolean }>,
  protocol?: { current: 'v1', supported: ['v1'] },
  policy?: { permission: PermissionPolicy },
}

workspaceCwd is the canonical primary workspace path (see 02-serve-runtime.md). Current daemons use workspaces[] as the registered runtime catalog; multi_workspace_sessions indicates that more than one runtime is active. policy.permission is the active mediator policy.

ServeCapabilityDescriptor

interface ServeCapabilityDescriptor {
  since: ServeProtocolVersion; // current = 'v1'
  modes?: readonly string[]; // lists operation modes when a feature has modes
}

Four v1 tags use modes:

  • mcp_guardrails: { since: 'v1', modes: ['warn', 'enforce'] } - clients should preflight 'enforce' before relying on refusal behavior.
  • permission_mediation: { since: 'v1', modes: ['first-responder', 'designated', 'consensus', 'local-only'] } - this is the build-time supported set; the active policy is in policy.permission.
  • workspace_voice_transcription: { since: 'v1', modes: ['batch'] } - the transcription path the daemon offers.
  • voice_transcribe: { since: 'v1', modes: ['streaming', 'batch'] } - the two transcription paths available on the /voice/stream WebSocket.

Conditional tags

export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
  ServeFeature,
  (toggles: AdvertiseFeatureToggles) => boolean
> = new Map([
  ['require_auth', (t) => t.requireAuth === true],
  ['mcp_workspace_pool', (t) => t.mcpPoolActive === true],
  ['mcp_pool_restart', (t) => t.mcpPoolActive === true],
  ['allow_origin', (t) => t.allowOriginActive === true],
  [
    'prompt_absolute_deadline',
    (t) => typeof t.promptDeadlineMs === 'number' && t.promptDeadlineMs > 0,
  ],
  [
    'writer_idle_timeout',
    (t) =>
      typeof t.writerIdleTimeoutMs === 'number' && t.writerIdleTimeoutMs > 0,
  ],
  ['workspace_settings', (t) => t.persistSettingAvailable === true],
  ['workspace_voice', (t) => t.persistSettingAvailable === true],
  [
    'workspace_voice_transcription',
    (t) => t.voiceTranscriptionAvailable === true,
  ],
  ['session_shell_command', (t) => t.sessionShellCommandEnabled === true],
  [
    'multi_workspace_session_rewind',
    (t) => t.multiWorkspaceSessionsEnabled === true,
  ],
  [
    'multi_workspace_session_shell',
    (t) =>
      t.multiWorkspaceSessionsEnabled === true &&
      t.sessionShellCommandEnabled === true,
  ],
  ['rate_limit', (t) => t.rateLimit === true],
  ['workspace_reload', (t) => t.reloadAvailable === true],
  ['voice_transcribe', (t) => t.voiceWsAvailable !== false],
]);

The Map stores membership and predicate together. Adding a new conditional tag requires two coordinated changes:

  1. Register the tag and its since version in SERVE_CAPABILITY_REGISTRY.
  2. Add its predicate to CONDITIONAL_SERVE_FEATURES.

Baseline tags are not present in the Map and are advertised unconditionally. This is intentionally represented by absence rather than by a separate Set.

v1 tags grouped by domain

Foundation: health, daemon_status, capabilities.

Sessions: session_create, session_scope_override, session_load, session_resume, unstable_session_resume, session_list, session_info, session_prompt, session_cancel, session_events, session_set_model, session_close, session_metadata, session_archive, session_export, session_transcript, session_context, session_context_usage, session_supported_commands, session_tasks, session_monitor_tool_correlation, session_stats, session_lsp, session_status, session_approval_mode_control, session_recap, session_btw, session_shell_command (conditional), session_language, session_rewind, session_hooks, session_branch.

Streaming: slow_client_warning, typed_event_schema.

Identity and heartbeat: client_identity, client_heartbeat.

Permissions: session_permission_vote, permission_vote, permission_mediation (modes: ['first-responder', 'designated', 'consensus', 'local-only']).

Workspace read-only snapshots: workspace_mcp, workspace_skills, workspace_providers, workspace_acp_status, workspace_env, workspace_preflight, workspace_hooks, workspace_extensions.

Extension management: extension_management_v2 adds the global /extensions/* catalog/mutation/operation contract and the workspace activation projection. It is separate from the published workspace_extensions compatibility surface and from workspace_qualified_rest_core.

Workspace-qualified session reads: workspace_persisted_transcript, workspace_session_export, workspace_archived_session_export. The active and archived export tags are independent from each other and from session_export and workspace_qualified_rest_core, so clients must pre-flight the exact storage state they intend to export. Persisted transcript paging permits an untrusted secondary under its bounded read policy; both full export paths remain trusted-only.

Workspace mutation (Wave 4+): workspace_memory, workspace_agents, workspace_agent_generate, workspace_acp_preheat, workspace_tool_toggle, workspace_settings (conditional), workspace_permissions, workspace_init, workspace_github_setup, workspace_trust, workspace_mcp_restart, workspace_mcp_manage, workspace_file_read, workspace_file_bytes, workspace_file_read_cursor, workspace_file_write, workspace_reload (conditional).

MCP guardrails: mcp_guardrails (modes: ['warn', 'enforce']), mcp_guardrail_events, mcp_server_runtime_mutation, mcp_workspace_pool (conditional), mcp_pool_restart (conditional).

Prompt control: prompt_absolute_deadline (conditional), writer_idle_timeout (conditional), non_blocking_prompt.

Auth: auth_provider_install, auth_device_flow, require_auth (conditional), allow_origin (conditional).

Voice: workspace_voice (conditional), workspace_voice_transcription (conditional, modes: ['batch']), voice_transcribe (conditional, modes: ['streaming', 'batch']).

Rate limiting: rate_limit (conditional).

Multi-workspace session routing: multi_workspace_sessions (conditional), multi_workspace_session_rewind (conditional), and multi_workspace_session_shell (conditional). A client may use rewind for a primary session with session_rewind; a secondary live session additionally requires multi_workspace_session_rewind. Shell uses the equivalent session_shell_command plus multi_workspace_session_shell pairing for a secondary session. ACP-native clients continue to use the _qwen.methods returned by initialize; no ACP rewind vendor method is advertised.

Bold tags have modes or are conditional.

Flow

Daemon side: assemble envelope

flowchart LR
    A["GET /capabilities"] --> B["getAdvertisedServeFeatures(version, toggles)"]
    B --> C["filter by isFeatureAvailableInProtocol"]
    C --> D["for each feature, check CONDITIONAL_SERVE_FEATURES"]
    D --> E["yes: predicate(toggles) ? include : drop"]
    D --> F["no: include unconditionally"]
    E --> G["return ServeFeature[]"]
    F --> G
    G --> H["wrap in envelope:<br/>{ v: 1, mode, features, workspaceCwd, protocol, policy }"]

Client side: feature preflight

sequenceDiagram
    autonumber
    participant C as Client
    participant D as GET /capabilities
    participant R as Route

    C->>D: GET /capabilities
    D-->>C: { v, mode, features, workspaceCwd, protocol, policy }
    C->>C: features.includes('mcp_workspace_pool')?
    alt yes
        C->>R: rely on pool-aware response shapes<br/>(for example entries[] from /workspace/mcp/:server/restart)
    else no
        C->>R: legacy single-entry response shape
    end

State and lifecycle

  • CAPABILITIES_SCHEMA_VERSION is the wire envelope shape version, currently 1. Bump it only for an envelope break.
  • SERVE_PROTOCOL_VERSION = 'v1' is the protocol-feature version. Adding features inside v1 is additive; old clients do not see new behavior unless they preflight the new tag. Removing a feature is a v2 break.
  • EVENT_SCHEMA_VERSION = 1 is the SSE frame v field (see 09-event-schema.md). It is an independent version axis; bumping event schema does not imply bumping protocol version, and vice versa.
  • session_resume is the stable daemon capability for POST /session/:id/resume. unstable_session_resume remains advertised as a deprecated alias because the underlying ACP method is still named connection.unstable_resumeSession; new clients should feature-detect session_resume.

Dependencies

  • Read by packages/cli/src/serve/server.ts when building /capabilities responses.
  • Toggle input comes from runQwenServe / createServeApp, including authentication, MCP, origin, prompt, settings, shell, rate-limit, reload, and live workspace-runtime-count state.
  • The active permission policy in the envelope comes from BridgeOptions.permissionPolicy, which itself reads settings.json policy.permissionStrategy.

Configuration

Source Knob Effect on capabilities
CLI flag --require-auth Advertises require_auth.
Env QWEN_SERVE_NO_MCP_POOL=1 Stops advertising mcp_workspace_pool and mcp_pool_restart; MCP events no longer stamp scope: 'workspace'.
CLI flag --mcp-client-budget=N, --mcp-budget-mode={off,warn,enforce} Does not change the tag set (mcp_guardrails is always advertised), but changes per-server reservation and refusal behavior.
CLI flag / env --rate-limit / QWEN_SERVE_RATE_LIMIT=1 Advertises rate_limit.
Embedded option persistSettingAvailable Advertises workspace_settings and workspace_voice.
Embedded option voiceTranscriptionAvailable Advertises workspace_voice_transcription.
CLI flag / embedded option --enable-session-shell / sessionShellCommandEnabled Advertises session_shell_command.
Runtime state More than one registered workspace runtime Advertises multi_workspace_sessions and multi_workspace_session_rewind; also advertises multi_workspace_session_shell when session shell is effectively enabled.
Embedded option reloadAvailable Advertises workspace_reload.
Embedded option voiceWsAvailable Advertises voice_transcribe.
settings.json policy.permissionStrategy Sets envelope policy.permission.

Caveats and known limits

  • --require-auth hides preflight. With --require-auth, all routes, including /capabilities, require bearer auth. An unauthenticated client cannot preflight caps.features.require_auth; the 401 response body is the discovery surface. The require_auth tag is an authenticated confirmation for hardened-deployment audit UIs.
  • Tag presence means behavior exists. If a future contributor adds behavior under an existing tag without bumping since, clients that preflighted the old tag can silently receive new behavior. The convention is: new behavior gets a new tag.
  • unstable_* tags can change shape between versions without a protocol bump. Pin an SDK version when depending on them.
  • The route catalog lives in ../qwen-serve-protocol.md; this page intentionally does not duplicate it.

References

  • packages/cli/src/serve/capabilities.ts
  • packages/cli/src/serve/types.ts (ServeOptions, CapabilitiesEnvelope)
  • packages/cli/src/serve/server.ts (envelope assembly)
  • packages/acp-bridge/src/eventBus.ts (EVENT_SCHEMA_VERSION)
  • Wire reference: ../qwen-serve-protocol.md
  • Auth and deployment guardrails: 12-auth-security.md