Merge remote-tracking branch 'origin/master' into worktree/custom-deepseek-models

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
#	.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md
#	apps/web/tests/snapshots/message-actions/ui.expected.md
#	apps/web/tests/snapshots/seeded-history/ui.expected.md
#	packages/client/ui-models/README.i18n.yaml
#	packages/client/ui-models/README.md
#	packages/client/ui-models/README.zh.md
#	packages/client/ui-models/src/client/ModelsSection.module.css
#	packages/client/ui-models/src/client/ModelsSection.tsx
#	packages/client/ui-models/tests/components.spec.tsx
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-31 14:36:00 +08:00
161 changed files with 4441 additions and 654 deletions
+23 -15
View File
@@ -72,6 +72,9 @@ import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/** Non-model settings namespaces intentionally served to the Web client. */
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
@@ -1081,31 +1084,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
/**
* The settings namespaces this proxy serves: exactly those a registered
* configurable provider addresses. The settings seam itself is general —
* any plugin may register a namespace for its own configuration — but the
* Web configuration plane is scoped to model providers, and that boundary
* has to be enforced here rather than assumed from the current plugin set.
* Without it, every future `settings.register()` would silently become
* remotely readable and writable configuration.
*/
function exposedNamespaces(): Set<string> {
/** Settings namespaces whose changes can invalidate the model catalog. */
function modelProviderNamespaces(): Set<string> {
return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs))
}
/** Refuse a namespace outside the model-provider boundary, naming why. */
/**
* The settings namespaces this proxy serves: configurable model providers
* plus the small explicit Web preference allowlist. The settings seam
* remains general; a future registration does not become remotely readable
* or writable by default.
*/
function exposedNamespaces(): Set<string> {
const exposed = modelProviderNamespaces()
for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns)
return exposed
}
/** Refuse a namespace outside the explicit configuration-client boundary. */
function notExposed(request: RpcRequest<unknown>, ns: string): RpcResponse<SettingsNamespaceView> {
return err(request, {
code: 'settings-not-exposed',
message: `settings namespace "${ns}" is not exposed to configuration clients; only a namespace a registered model provider addresses is`,
message: `settings namespace "${ns}" is not exposed to configuration clients`,
details: { ns },
})
}
/**
* Run one settings write (merge or wholesale replace) and acknowledge with
* the namespace's new redacted view. A namespace outside the model-provider
* the namespace's new redacted view. A namespace outside the configuration
* boundary is refused before the seam is touched; every seam refusal —
* unknown or invalid namespace, read-only provider, schema validation,
* storage — becomes one `settings-rejected` carrying the seam's own message.
@@ -2148,11 +2155,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// inherited to overridden leaves the resolved value equal, and a
// configuration client still has to re-read (its held revision is
// stale, and the field's meaning changed).
queue.push(frame({ type: 'host/settings-changed', ns: String(ns) }))
const name = String(ns)
queue.push(frame({ type: 'host/settings-changed', ns: name }))
// A provider's own settings carry its model catalog and endpoint,
// so a change there invalidates the model list even when the route
// set is untouched — `llm/adapters-updated` alone misses it.
if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' }))
if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' }))
}),
ctx.on('credentials/updated', (ref) => {
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
+22 -12
View File
@@ -59,9 +59,10 @@ import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
* methods take the business payload directly — the carrier mints the rpcId and wraps the
* envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
* Unary methods and respond accept an optional external AbortSignal as the last parameter
* (merged with the instance timeout via AbortSignal.any; same "signal rides beside the
* request, never on the wire" discipline as the stream signatures).
* Unary methods and respond accept an optional external AbortSignal as the last parameter.
* Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
* carry only that external signal. In both cases the signal rides beside the request, never
* on the wire, like the stream signatures.
* Stream methods accept an optional onOpen callback: it fires once the SSE transport is
* readable (response headers received, before any frame) — the "stream established" signal
* connection controllers need for the readiness handshake. Generators are lazy, so the
@@ -182,9 +183,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'llm.models': llmModelsValueSchema,
}
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
const DEFAULT_TIMEOUT_MS = 30_000
/** Whether a unary call uses the transport health deadline or only caller/connection cancellation. */
type UnaryTimeoutPolicy = 'default' | 'caller-signal-only'
/** URL base for in-process handler injection (fake authority, opencode precedent). */
const INTERNAL_BASE = 'http://dsh.internal'
@@ -202,7 +206,7 @@ export abstract class AbstractApiClient implements IApiClient {
private flushScheduled = false
private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
/** @param timeoutMs - unary timeout; streams never time out (long-lived by nature). */
/** @param timeoutMs - timeout for bounded unary calls; user-paced calls and streams do not use it. */
constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
/** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
@@ -257,15 +261,15 @@ export abstract class AbstractApiClient implements IApiClient {
/**
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
* optional default timeout merged with the caller's external signal, non-2xx → transport throw.
*/
private async postJson(
path: string,
body: ClientRequest | ClientResponse,
signal: AbortSignal | undefined,
useDefaultTimeout = true,
timeoutPolicy: UnaryTimeoutPolicy = 'default',
): Promise<Response> {
const requestSignal = useDefaultTimeout
const requestSignal = timeoutPolicy === 'default'
? signal === undefined
? AbortSignal.timeout(this.timeoutMs)
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
@@ -289,11 +293,11 @@ export abstract class AbstractApiClient implements IApiClient {
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
useDefaultTimeout = true,
timeoutPolicy: UnaryTimeoutPolicy = 'default',
): Promise<RpcResponse<ResponseValue<K>>> {
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
this.onEnvelope(message)
const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout)
const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy)
const full = serverResponseSchema.parse(await response.json())
this.onEnvelope(full)
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
@@ -382,7 +386,9 @@ export abstract class AbstractApiClient implements IApiClient {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
// A native system dialog is user-paced and may legitimately stay open
// longer than the normal unary deadline. Caller/connection aborts remain.
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
pickDirectory: (payload, signal) => this.callUnary(
'host.pickDirectory', payload, signal, 'caller-signal-only',
),
listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal),
createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal),
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
@@ -398,7 +404,11 @@ export abstract class AbstractApiClient implements IApiClient {
readonly commands: IApiClient['commands'] = {
list: (payload, signal) => this.callUnary('command.list', payload, signal),
execute: (payload, signal) => this.callUnary('command.execute', payload, signal),
// Command handlers are user-driven operations and may legitimately exceed
// the transport health deadline. Caller/connection aborts remain.
execute: (payload, signal) => this.callUnary(
'command.execute', payload, signal, 'caller-signal-only',
),
}
readonly skills: IApiClient['skills'] = {