Merge commit '25de3fbe05f0c1561344d21c2e6280529fbefb20' into codex/product-subagent-presets
This commit is contained in:
@@ -65,7 +65,7 @@
|
||||
- id: agent
|
||||
name: '@deepseek-ai/dsh-agent'
|
||||
|
||||
# The transport-independent default for Agents created by front doors.
|
||||
# The transport-independent default for Agents created by entry points.
|
||||
# Settings may supply a saved selection; consumers read it at creation time.
|
||||
- id: agent-default-model
|
||||
name: '@deepseek-ai/dsh-agent-default-model'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
|
||||
README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee
|
||||
README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c
|
||||
README.md: d8e88cb7b0215b06cd55a4ee9a7932ef180f572f
|
||||
README.zh.md: 073a41cac95aeb96b658b075011d8212684f1c65
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`.
|
||||
|
||||
A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan.
|
||||
A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan.
|
||||
|
||||
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
|
||||
|
||||
pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。
|
||||
pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每个入口注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。
|
||||
|
||||
`skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* determinism
|
||||
* lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a
|
||||
* leading `/name` naming a user-invocable skill and injects the rendered
|
||||
* body for every front end, including `disable-model-invocation` skills the
|
||||
* body for every entry point, including `disable-model-invocation` skills the
|
||||
* model-side catalog never lists (issue #1470). The RPC rides the plugin's
|
||||
* root-context connection captured at registration — the source never reads
|
||||
* services off a per-call argument. Draft chip visuals derive from
|
||||
@@ -167,7 +167,7 @@ export function apply(ctx: ClientContext): void {
|
||||
// lands plain text and the prompt ships the same
|
||||
// literal. Determinism lives host-side — the host's
|
||||
// pre-step boundary (dsh-tool-skill) recognizes the leading /name and
|
||||
// injects the rendered body for every front end. A name shared with a
|
||||
// injects the rendered body for every entry point. A name shared with a
|
||||
// host command still resolves to the command: adjudication claims the
|
||||
// line client-side before it ever becomes a prompt.
|
||||
return { text: `/${candidate.name} ` }
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/README.md
|
||||
README.md: 8349371ab565f2e9e735cd959026936c7ec44081
|
||||
README.md: 504686f8563f073fc8261c88275a5cdd172dd060
|
||||
README.zh.md: e19c446584cfac75cb50833fa01586688b9c7c92
|
||||
|
||||
@@ -11,10 +11,10 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, deploy
|
||||
| [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` |
|
||||
| [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` |
|
||||
| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent front doors | `ctx.agentDefaultModel` |
|
||||
| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent entry points | `ctx.agentDefaultModel` |
|
||||
| [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` |
|
||||
|
||||
`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent front door uses only when a session has no selection of its own.
|
||||
`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent entry point uses only when a session has no selection of its own.
|
||||
|
||||
Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces.
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-default-model/README.md
|
||||
README.md: 02bcc9be3adee2293a20b3ae87ddaf4d52e70deb
|
||||
README.md: 98bc7d082e62a764868f8acd323c4617e9839e61
|
||||
README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The deployment default used when a front door creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct front doors such as `dsh run` and Host-backed front doors such as ApiProxy read the same service instead of owning parallel provider/model defaults.
|
||||
The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults.
|
||||
|
||||
The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again.
|
||||
|
||||
@@ -13,7 +13,7 @@ The service does not validate catalog membership. A provider route may serve an
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the provider/model selection supplied to a front door; request assembly and adapters own the model-visible request.
|
||||
Indirectly, through the provider/model selection supplied to an entry point; request assembly and adapters own the model-visible request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -21,5 +21,5 @@ Changing the default affects only Agents that subsequently resolve from it. An e
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The service owns one process-wide default; per-session selection remains the front door's responsibility.
|
||||
- The service owns one process-wide default; per-session selection remains the entry point's responsibility.
|
||||
- Without a settings provider, `saveSelection()` cannot retain a selection for a later Agent.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-default-model",
|
||||
"description": "Default model selection shared by Agent front doors",
|
||||
"description": "Default model selection shared by Agent entry points",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -92,7 +92,7 @@ export class AgentDefaultModelService extends Service {
|
||||
/**
|
||||
* Save the complete default model selection. A deployment without a settings
|
||||
* provider keeps its composition entry.
|
||||
* @param next - resolved selection accepted by a front door.
|
||||
* @param next - resolved selection accepted by an entry point.
|
||||
* @returns fulfillment after the optional settings write settles.
|
||||
*/
|
||||
async saveSelection(next: ModelSelection): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Agent-scoped model selection shared by interactive front doors.
|
||||
* Agent-scoped model selection shared by runtime entry points.
|
||||
* @module @deepseek-ai/dsh-agent/model-selection
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ModelSelectionRef {
|
||||
* the selected model's provider/default behavior.
|
||||
*
|
||||
* @param agentCtx - The selected Agent's scoped context.
|
||||
* @param selection - Mutable selection owned by the calling front door.
|
||||
* @param selection - Mutable selection owned by the calling entry point.
|
||||
* @returns Disposer for both scoped waterfall listeners.
|
||||
*/
|
||||
export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/examples/README.md
|
||||
README.md: 2d672dcc307bb280cf3803f29128eba4988a8da0
|
||||
README.zh.md: 24f64096dda0ccdac51afb90754ae25950750b89
|
||||
README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944
|
||||
README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and a front door by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry.
|
||||
Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and an entry point by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry.
|
||||
|
||||
| Package | npm name | Role |
|
||||
|---|---|---|
|
||||
@@ -10,8 +10,8 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|
||||
| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle |
|
||||
| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime |
|
||||
|
||||
`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation front door, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it.
|
||||
`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it.
|
||||
|
||||
These packages are not product API. Product seams and front doors remain in their owning groups; demo bundles select concrete compositions.
|
||||
These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions.
|
||||
|
||||
Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。
|
||||
预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和运行入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。
|
||||
|
||||
| 包 | npm 名称 | 角色 |
|
||||
|---|---|---|
|
||||
@@ -12,6 +12,6 @@
|
||||
|
||||
`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。
|
||||
|
||||
这些包不是产品 API。产品 seam 与前端入口仍位于各自的归属组;演示组合包选择具体组合。
|
||||
这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。
|
||||
|
||||
不要将此组与仓库根目录的 [`examples/`](../../examples/AGENTS.md) 混淆:该目录存放可运行的 `cordis.yml` **叶节点**;此组存放这些叶节点加载的 **组合包**。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/examples/acp-demo/README.md
|
||||
README.md: 395ab230146568989c4e6d1361218efb72d857e7
|
||||
README.zh.md: 667fc1a794eba15c7754ad9887f8083d3643f3e6
|
||||
README.md: edc45c9857a631cef72eb41b1a98c390f112291e
|
||||
README.zh.md: c2946aa3d1feaed558408cf0921e2480c031187d
|
||||
|
||||
@@ -55,4 +55,4 @@ Append-only per session; the app adds no request-prefix content itself.
|
||||
|
||||
- **JSONL persistence is fixed** — a different backend requires another composition.
|
||||
- **Sibling plugins can corrupt stdout** — the app cannot prevent another entry from writing non-protocol bytes.
|
||||
- **Fresh automation sessions only** — resume and human interaction belong to other front doors.
|
||||
- **Fresh automation sessions only** — resume and human interaction belong to other entry points.
|
||||
|
||||
@@ -55,4 +55,4 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能
|
||||
|
||||
- **JSONL 持久化固定不变**:使用其他后端需要另一种组合。
|
||||
- **同级插件可能破坏 stdout**:应用无法阻止另一个条目写入非协议字节。
|
||||
- **只支持新建自动化会话**:恢复和人工交互属于其他前端入口。
|
||||
- **只支持新建自动化会话**:恢复和人工交互属于其他运行入口。
|
||||
|
||||
@@ -71,7 +71,7 @@ export interface Config {
|
||||
goals?: agentCore.GoalConfig | false
|
||||
}
|
||||
|
||||
// Each front door owns a complete, directly readable config schema; extracting
|
||||
// Each entry point owns a complete, directly readable config schema; extracting
|
||||
// the common fields would make two small app contracts depend on a new facade.
|
||||
/* jscpd:ignore-start */
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -114,7 +114,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
|
||||
await spine
|
||||
yield spine.dispose
|
||||
// Same rationale as the Config schema above: each front door forwards its own
|
||||
// Same rationale as the Config schema above: each entry point forwards its own
|
||||
// persistence passthroughs rather than sharing a facade with stdio-demo.
|
||||
/* jscpd:ignore-start */
|
||||
const persistence = ctx.plugin(SessionPersistenceJsonl, {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md
|
||||
README.md: cfec2c46ada8ed97aef44fb1d4145ddecdeecab1
|
||||
README.zh.md: d482ea9ca7034874383472050a8c837bea5bfaad
|
||||
README.md: cf0dc2ecd6e51eb872be75dfe6d80a5338605195
|
||||
README.zh.md: e5a8672d494e0c456aa820641e685d00be624445
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends.
|
||||
The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only an entry point and the swappable backends.
|
||||
|
||||
Read this package for the whole plugin tree and its composition order.
|
||||
|
||||
@@ -41,15 +41,15 @@ Read this package for the whole plugin tree and its composition order.
|
||||
|
||||
## What it deliberately leaves OUTSIDE the bundle
|
||||
|
||||
The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle:
|
||||
The spine is everything COMMON to every entry point. The swappable and entry-point-coupled pieces stay out, picked by whatever loads the bundle:
|
||||
|
||||
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
|
||||
- **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider.
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **front-door + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent.
|
||||
- **entry point + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent.
|
||||
|
||||
This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
|
||||
This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the entry point.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -65,9 +65,9 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling.
|
||||
A YAML include can deduplicate config but cannot own a bin or provide entry-point defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling.
|
||||
|
||||
The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse.
|
||||
The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, entry points derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加前端入口和可替换后端,就能组合出可工作的 agent。
|
||||
将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加入口和可替换后端,就能组合出可工作的 agent。
|
||||
|
||||
阅读此包可了解完整插件树及其组合顺序。
|
||||
|
||||
@@ -41,15 +41,15 @@
|
||||
|
||||
## 有意留在组合包外的组件
|
||||
|
||||
主干包含每个前端入口都共有的全部组件。可替换组件和与前端入口耦合的组件留在外部,由加载组合包的一方选择:
|
||||
主干包含每个入口都共有的全部组件。可替换组件和与入口耦合的组件留在外部,由加载组合包的一方选择:
|
||||
|
||||
- **LLM(大语言模型)适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。
|
||||
- **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。
|
||||
- **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。
|
||||
- **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。
|
||||
- **前端入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。
|
||||
- **入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。
|
||||
|
||||
这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。
|
||||
这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有入口。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -65,9 +65,9 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
|
||||
## 为何使用代码组合包,而非共享 YAML include
|
||||
|
||||
YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。
|
||||
YAML include 可以去重配置,却无法拥有 bin 或提供入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。
|
||||
|
||||
重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。
|
||||
重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export const Config = z.intersect([
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
* Copy the bundle-owned fields from an app config without leaking front-door settings.
|
||||
* Copy the bundle-owned fields from an app config without leaking entry-point settings.
|
||||
* @param config - App config containing the shared spine fields.
|
||||
* @returns The fields accepted by this bundle, preserving optional absence.
|
||||
*/
|
||||
|
||||
@@ -236,7 +236,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('loads and configures bounded request recovery for every bundled front door', async () => {
|
||||
it('loads and configures bounded request recovery for every bundled entry point', async () => {
|
||||
const adapter = new TransientOnceAdapter()
|
||||
const ctx = await mount({ workspaceContext: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -704,9 +704,9 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('picks shared spine config without leaking front-door fields', () => {
|
||||
it('picks shared spine config without leaking entry-point fields', () => {
|
||||
const appConfig = {
|
||||
model: 'front-door-only',
|
||||
model: 'entrypoint-only',
|
||||
includeHarnessIdentity: false,
|
||||
persona: 'You are merged.',
|
||||
toolOrder: ['zulu'],
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md
|
||||
README.md: e2eb6d4cf2b40e83efad1fa158edd72578658f56
|
||||
README.md: d7849e25fc62897e4ac6793f40bdc139adf9ba3d
|
||||
README.zh.md: c3b7b59d90d924de6042aeac1e7eec39457c6c83
|
||||
|
||||
@@ -56,4 +56,4 @@ Independent of the model request path. Recording appends to the session log only
|
||||
- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text.
|
||||
- **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one.
|
||||
- **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`.
|
||||
- **Web only in the shipped front doors** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there.
|
||||
- **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there.
|
||||
|
||||
@@ -143,7 +143,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const groups: MatcherGroup[] = parsed[point] ?? []
|
||||
const outputs: HookOutput[] = []
|
||||
// Run the hook in the agent's session workspace (the `session/new` cwd on the session
|
||||
// header), not the executor or front-door process's launch dir.
|
||||
// header), not the executor or entry-point process's launch dir.
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session
|
||||
// workspace (the same dir the hook runs in).
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 98fcdda155286feb23aaf294cab76529ce31cfc1
|
||||
README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c
|
||||
README.md: c29f30b85c5579f278ac9b40a0422347502eeb8f
|
||||
README.zh.md: 92b866bafd71902c55bf0bad14c6b9e761421cf8
|
||||
|
||||
@@ -50,13 +50,13 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
|
||||
|
||||
`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core front door and does not mount this package.
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* routes — physical carriers wrap `ctx.apiProxy` themselves.
|
||||
*
|
||||
* The gateway consumes `ctx.agentDefaultModel`, the transport-independent default
|
||||
* shared with direct front doors. Switching models persists through that
|
||||
* shared with direct entry points. Switching models persists through that
|
||||
* service; sessions that have already logged a selection remain unchanged.
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md
|
||||
README.md: 6c8ba23b76e83665d4f8dcb5ecb41689347f6423
|
||||
README.md: c404cfa73024804bc9f166cfb84fa5f87f723459
|
||||
README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410
|
||||
|
||||
@@ -18,7 +18,7 @@ The review question declares the `plan-review` presentation intent, naming `Appr
|
||||
|
||||
When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request.
|
||||
|
||||
The Web client consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary.
|
||||
The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary.
|
||||
|
||||
## Session projection
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
|
||||
README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6
|
||||
README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170
|
||||
README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d
|
||||
README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21
|
||||
|
||||
@@ -55,6 +55,8 @@ A row's **package name** resolves from the host composition, not from the preset
|
||||
|
||||
A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it.
|
||||
|
||||
An **absolute** filesystem path keeps its own location. The mount converts it to a `file:` URL before ESM import so POSIX paths and Windows drive-letter or UNC paths use a specifier Node accepts.
|
||||
|
||||
### Display metadata
|
||||
|
||||
A preset may publish display text in an optional `preset.yml` beside its composition:
|
||||
|
||||
@@ -55,6 +55,8 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
|
||||
|
||||
**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
|
||||
|
||||
**绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。
|
||||
|
||||
### 展示用元信息
|
||||
|
||||
preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本:
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* @module @deepseek-ai/dsh-agent-presets/mount
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import { Include } from '@cordisjs/plugin-include'
|
||||
@@ -69,21 +70,25 @@ class PresetTree extends Include {
|
||||
* where Node's upward `node_modules` walk never reaches the harness's own
|
||||
* dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The
|
||||
* mount records the host composition's base instead, which is inside the
|
||||
* installed harness, and bare names resolve from there.
|
||||
* installed harness, and bare names resolve from there. An absolute
|
||||
* filesystem path names neither base and becomes a file URL before Node's
|
||||
* ESM loader receives it, which is required for drive-letter paths on
|
||||
* Windows.
|
||||
* @param name - the module specifier from the row.
|
||||
* @param getOuterStack - the loader's stack composer for import diagnostics.
|
||||
* @returns the imported module, or the `cordis:` builtin.
|
||||
*/
|
||||
override import(name: string, getOuterStack?: () => string[]): unknown {
|
||||
const specifier = isAbsolute(name) ? pathToFileURL(name).href : name
|
||||
const base = harnessBase.get(this.config)
|
||||
/* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */
|
||||
if (base === undefined) return super.import(name, getOuterStack)
|
||||
if (base === undefined) return super.import(specifier, getOuterStack)
|
||||
if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack)
|
||||
const internal = this.ctx.loader.internal
|
||||
/* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a
|
||||
hypothetical embedder from losing the row's name in a resolution error. */
|
||||
if (internal === undefined) return super.import(name, getOuterStack)
|
||||
return internal.import(name, base, {})
|
||||
if (internal === undefined) return super.import(specifier, getOuterStack)
|
||||
return internal.import(specifier, base, {})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,20 +65,23 @@ describe('copying a preset', () => {
|
||||
expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user')
|
||||
})
|
||||
|
||||
it('copies the whole directory, execute bits kept and group/other stripped', async () => {
|
||||
it('copies the whole directory and tightens POSIX modes', async () => {
|
||||
await seedPreset(userRoot, 'source', {
|
||||
extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' },
|
||||
})
|
||||
await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755)
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755)
|
||||
}
|
||||
|
||||
await ctx.agentPresets.copy('source', 'mine')
|
||||
|
||||
expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n')
|
||||
// A preset may ship runnable helpers; the copy keeps them runnable for the
|
||||
// owner while withdrawing the world-readability of the install.
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600)
|
||||
expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700)
|
||||
// Windows mode bits are synthetic and cannot represent the inherited DACL.
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600)
|
||||
expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the source description but never its name or order', async () => {
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const fsHarness = vi.hoisted(() => ({
|
||||
nextReadError: undefined as NodeJS.ErrnoException | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
readFile: (async (path: unknown, ...rest: never[]) => {
|
||||
const error = fsHarness.nextReadError
|
||||
if (error !== undefined) {
|
||||
fsHarness.nextReadError = undefined
|
||||
throw error
|
||||
}
|
||||
return (actual.readFile as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
|
||||
}) as typeof actual.readFile,
|
||||
}
|
||||
})
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
|
||||
const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
|
||||
|
||||
beforeEach(() => {
|
||||
fsHarness.nextReadError = undefined
|
||||
})
|
||||
|
||||
describe('display order', () => {
|
||||
it('puts declared order first, then everything else by id', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-order-'))
|
||||
@@ -177,10 +200,11 @@ describe('composition health', () => {
|
||||
await mkdir(join(root, 'sealed'))
|
||||
const path = join(root, 'sealed', COMPOSITION_FILE)
|
||||
await writeFile(path, '[]\n')
|
||||
await chmod(path, 0o000)
|
||||
fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' })
|
||||
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' })
|
||||
|
||||
expect(fsHarness.nextReadError).toBeUndefined()
|
||||
expect(preset?.broken).toMatch(/cannot be read/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
@@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentPresets, {
|
||||
COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
@@ -84,6 +84,23 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
describe('composing an agent from a preset', () => {
|
||||
it('hands an absolute plugin path to Node as a file URL', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-'))
|
||||
const presetDir = join(root, 'absolute')
|
||||
const plugin = join(FIXTURES, 'plugins', 'contribute.js')
|
||||
await mkdir(presetDir)
|
||||
await writeFile(
|
||||
join(presetDir, COMPOSITION_FILE),
|
||||
`- id: only\n name: ${plugin}\n config:\n tool: absolute\n`,
|
||||
)
|
||||
const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] })
|
||||
const imported = vi.spyOn(scoped.loader.internal!, 'import')
|
||||
|
||||
await agentOn(scoped, 'sess-absolute-plugin')
|
||||
|
||||
expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {})
|
||||
})
|
||||
|
||||
it('gives each session only its own preset\'s tools', async () => {
|
||||
const alpha = await agentOn(ctx, 'sess-alpha', 'standard')
|
||||
const beta = await agentOn(ctx, 'sess-beta', 'minimal')
|
||||
@@ -525,6 +542,34 @@ describe('editing a composition file', () => {
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a newer generation pointer when a stale refresh loses the swap race', async () => {
|
||||
const { scoped, path } = await editable('guarded-refresh')
|
||||
const preset = await scoped.agentPresets.resolve('guarded-refresh')
|
||||
await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh')
|
||||
const service = scoped.agentPresets as unknown as {
|
||||
standing: Map<string, Promise<{
|
||||
key: unknown
|
||||
scope: unknown
|
||||
stamp: { mtimeMs: number; size: number }
|
||||
}>>
|
||||
ensureStanding(current: typeof preset): Promise<unknown>
|
||||
}
|
||||
const stalePromise = service.standing.get(preset.id)!
|
||||
const stale = await stalePromise
|
||||
await writeFile(path, rowFor('afterwards'))
|
||||
const { mtimeMs, size } = await stat(path)
|
||||
const newer = { ...stale, stamp: { mtimeMs, size } }
|
||||
const newerPromise = Promise.resolve(newer)
|
||||
|
||||
// `await pending` yields before the guarded delete, letting the winning
|
||||
// refresher replace the pointer deterministically instead of by timing.
|
||||
const refresh = service.ensureStanding(preset)
|
||||
service.standing.set(preset.id, newerPromise)
|
||||
|
||||
expect(await refresh).toBe(newer)
|
||||
expect(service.standing.get(preset.id)).toBe(newerPromise)
|
||||
})
|
||||
|
||||
it('hands a host reader the standing key without starting an agent', async () => {
|
||||
const { scoped } = await editable('cold-read')
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class AppOption extends FeatureOption {
|
||||
this.label = label
|
||||
}
|
||||
|
||||
/** Identify options by their unique front door, not the shared interaction service. */
|
||||
/** Identify external options by their run interface, not the shared interaction service. */
|
||||
override markerConfigEntries(): readonly { id: string; name: string }[] {
|
||||
switch (this.id) {
|
||||
case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }]
|
||||
@@ -58,7 +58,7 @@ class AppOption extends FeatureOption {
|
||||
}
|
||||
}
|
||||
|
||||
/** Embed is identified by the configured loop with no external front door. */
|
||||
/** Embed is identified by the configured loop and absence of an external entry point. */
|
||||
override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean {
|
||||
if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile)
|
||||
return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop')
|
||||
@@ -93,7 +93,7 @@ export class AppFeature extends ExclusiveOptionFeature {
|
||||
new AppOption('embed', 'Embedded context'),
|
||||
]
|
||||
|
||||
/** Default to the profile's already selected front door. */
|
||||
/** Default to the profile's already selected run interface. */
|
||||
override defaultOptions(profile: ProjectProfile): readonly string[] {
|
||||
return [profile.runInterface]
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export abstract class Feature {
|
||||
readonly requires: readonly FeatureId[] = []
|
||||
/** Features recommended during creation. */
|
||||
readonly suggests: readonly FeatureId[] = []
|
||||
/** Front doors under which this feature is meaningful. */
|
||||
/** Run interfaces under which this feature is meaningful. */
|
||||
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed']
|
||||
|
||||
/**
|
||||
@@ -141,7 +141,7 @@ export abstract class Feature {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the feature may be selected for this project front door.
|
||||
* Whether the feature may be selected for this project run interface.
|
||||
* @param profile - project context to check.
|
||||
* @returns whether the feature applies.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { PackageManager } from '../package-managers/package-manager.ts'
|
||||
import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
|
||||
import type { FeatureId } from '../ids.ts'
|
||||
|
||||
/** Runtime front door selected for a generated project. */
|
||||
/** Run interface selected for a generated project. */
|
||||
export type RunInterface = 'acp' | 'embed'
|
||||
|
||||
/** Values shared by the required provider and app features. */
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks')
|
||||
})
|
||||
|
||||
it('round-trips embed app projects without a front-door Cordis config entry', async () => {
|
||||
it('round-trips embed app projects without an ACP Cordis config entry', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-'))
|
||||
temporary.push(root)
|
||||
const creation = request([], [], 'embed')
|
||||
|
||||
@@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async saveSelection(next: ModelSelection): Promise<void>',
|
||||
jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by a front door.\n * @returns fulfillment after the optional settings write settles.\n */',
|
||||
jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by an entry point.\n * @returns fulfillment after the optional settings write settles.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
|
||||
* A write-through console for one sandbox, tagging every line with the mount
|
||||
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
|
||||
* a mounted listener fires long after the mount call returned, and its output
|
||||
* must land somewhere the user can see — for a terminal front door, the host terminal.
|
||||
* must land somewhere the user can see — for a terminal entry point, the host terminal.
|
||||
*/
|
||||
function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
|
||||
const tag = `[cordis:${id}]`
|
||||
|
||||
Reference in New Issue
Block a user