Merge remote-tracking branch 'origin/master' into feat/cua-basic-onboarding

This commit is contained in:
creatixchu committed 2026-10-19 14:11:34 +08:00
commit 8dc477498f
38 files changed
+493 -334

No files matched your search

+59 -27
View File
@@ -1,6 +1,6 @@
---
name: dsh-pr-description
description: Use when creating a deepseek-harness pull request, or when updating its description after later changes or a handled review round. Defines each description section, Issue references, test design, change-specific disclosures, and the Human and Agent Roles section with its levels, stages, grading rules, and update timing.
description: Use when creating a deepseek-harness pull request, or when updating its description after later changes or a handled review round. Defines each description section, Issue references, test design, change-specific disclosures, and the AI Usage section with its levels, stages, grading rules, AI-DECLARATION badge, and update timing.
user-invocable: false
---
@@ -16,9 +16,9 @@ This skill defines what a deepseek-harness PR description contains, how to write
## Description structure
The description uses four second-level headings, in this order: `## Motivation`, `## Changes`, `## Testing`, and `## Human and Agent Roles`.
The description uses four second-level headings, in this order: `## Motivation`, `## Changes`, `## Testing`, and `## AI Usage`.
Write all headings and labels, such as **Interface changes**, **Behavior changes**, **Test design**, and **Test entries**, in English. Write the rest of the description in Chinese unless a human asked for another language for this PR.
Write fixed section headings and labels, such as **Interface changes**, **Behavior changes**, **Test design**, and **Test entries**, in English. Write table headers and the rest of the description in the PR description's language, Chinese unless a human asked for another language.
### Motivation
@@ -63,22 +63,22 @@ Write each disclosure in the section it concerns, usually Changes, after a bold
| Changing product-user-visible GUI behavior | An embedded demonstration GIF, with the disclosures that record-browser-gif requires beside the embed; for each GUI change without a GIF, a statement that none is embedded and why | [record-browser-gif](../record-browser-gif/SKILL.md#every-gui-pull-request-includes-a-gif) |
| Using a translation term the terminology list lacks | The source of the rendering, or a proposed rendering under pending terms | [Translation rules](../../../docs/i18n/translation-rules.md) |
## Human and Agent Roles
## AI Usage
This section records who made the decisions in each stage of the PR, and how autonomously the Agent worked.
This section records how much the AI took part in each stage of the PR, and who made the decisions.
### Levels
Levels are ordered from least to most AI involvement; each level pairs an English name with a Chinese name. Autonomy is the Agent's autonomy in that stage, a multiple of 5% within the chosen level's range.
Levels are ordered from least to most AI involvement; each level pairs an English name with a Chinese name. AI involvement is how much the AI participated in the decisions and work of that stage, recorded as a multiple of 5% within the chosen level's range. It is not a measure of code volume, time, or tokens.
| Level | Meaning | Agent autonomy |
| Level | Meaning | AI involvement |
|---|---|---|
| hint 人工独立 | A human does the work; AI takes no part or only offers passive suggestions that the human accepts or rejects | 0–10% |
| assist 人工主导 | A human supplies the main content or every key decision; AI completes part of the work | 15–25% |
| pair 人工引导 | A human gives the key direction or constraints and revises the AI's output several times; the human understands the internals | 30–50% |
| copilot 人工把关 | AI completes the whole task; a human reviews, selects, or rejects, and decides or clarifies key points without supplying substantive content | 55–85% |
| auto 模型自主 | AI completes the task autonomously; a human only sets the goal or accepts the output | 90–100% |
| n/a 暂不适用 | The stage does not exist or has not happened yet | Omitted |
| n/a 暂不适用 | The stage does not exist or has not happened yet | — |
### Stages and criteria
@@ -112,7 +112,7 @@ Stages appear in a fixed order; each stage pairs an English name with a Chinese
- pair 人工引导: a human requires reverting or redoing any part of the work, or specifies a refactoring or an implementation approach.
- assist 人工主导: a human writes the key parts in the diff; the Agent completes the rest. Dictating content for the Agent to write is not writing it.
**review 评审处理**: who decides whether each handled review comment is fixed or declined. Comments from bots and from people both count; grade by who decided, not by who posted the reply. When no review comment has been handled, the line stays n/a, even if comments exist or arrived after merge.
**review 评审处理**: who decides whether each handled review comment is fixed or declined. Comments from bots and from people both count; grade by who decided, not by who posted the reply. When no review comment has been handled, the row stays n/a, even if comments exist or arrived after merge.
- auto 模型自主: the Agent decides on and replies to every comment.
- copilot 人工把关: a human gives only a blanket authorization or category-level instructions, or responds to only a few items on a handling list the Agent prepared and leaves the rest to the Agent.
@@ -125,32 +125,54 @@ Stages appear in a fixed order; each stage pairs an English name with a Chinese
- Count only work that ends up in this PR, including work from earlier sessions that ends up in this PR. A human's request to revert or redo work counts even when the reverted work does not remain in the PR.
- In breakdown, design, testing, and implementation, the human is the author working with the Agent; reviewers' approvals and comments count only in review.
- Process and ordering instructions, such as branching, working order, staging, and when to commit, push, or open the PR, do not count as human involvement in any stage.
- When the records suffice, grade at the level with the most human involvement whose criterion the records meet, and express the human and Agent shares through the autonomy value. Only when the records do not suffice, take the level with less human involvement that the records support.
- When the records suffice, grade at the level with the most human involvement whose criterion the records meet, and express the human and Agent shares through the AI involvement value. Only when the records do not suffice, take the level with less human involvement that the records support.
- When a stage covers several changes, grade by the main change and state how the other changes differ in the description. The main change is the one with the largest share of the diff and review effort; when no single change dominates, grade by the change with the most human involvement. When later work changes which change is main, regrade the stage.
- Within the chosen level's range, choose a lower autonomy value the closer the human involvement comes to the criterion of the level with more human involvement, and a higher value the closer it comes to the level with less.
- Within the chosen level's range, choose a lower AI involvement value the closer the human involvement comes to the criterion of the level with more human involvement, and a higher value the closer it comes to the level with less.
### Line format
### Badge URLs
The section starts with one level line, followed by one line per stage in the fixed order:
The AI-DECLARATION block pins specification version 0.1.2. Use the badge URL for the level selected in [Section format](#section-format); the badges follow [AI-DECLARATION](https://ai-declaration.md).
| Level | Badge URL |
|---|---|
| hint 人工独立 | `https://img.shields.io/badge/䷼%20AI--DECLARATION-hint-ecfccb?labelColor=ecfccb` |
| assist 人工主导 | `https://img.shields.io/badge/䷼%20AI--DECLARATION-assist-fef9c3?labelColor=fef9c3` |
| pair 人工引导 | `https://img.shields.io/badge/䷼%20AI--DECLARATION-pair-ffedd5?labelColor=ffedd5` |
| copilot 人工把关 | `https://img.shields.io/badge/䷼%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2` |
| auto 模型自主 | `https://img.shields.io/badge/䷼%20AI--DECLARATION-auto-ede9fe?labelColor=ede9fe` |
When the upstream specification changes, update every version reference in this skill and the PR template.
### Section format
The section starts with the AI-DECLARATION block, followed by one table row per stage in the fixed order:
```markdown
## Human and Agent Roles
## AI Usage
Levels and Agent autonomy: <ins>**hint 人工独立**</ins> 0–10%, <ins>**assist 人工主导**</ins> 15–25%, <ins>**pair 人工引导**</ins> 30–50%, <ins>**copilot 人工把关**</ins> 55–85%, <ins>**auto 模型自主**</ins> 90–100%; <ins>**n/a 暂不适用**</ins> marks a stage that does not exist or has not happened yet.
<!-- ai-declaration:start -->
[![AI-DECLARATION: <highest level>](<badge URL>)](https://ai-declaration.md)
- <ins>**<level English name> <level Chinese name>**</ins> <autonomy> · <stage English name> <stage Chinese name> — <description>
**AI declaration** ([v0.1.2](https://ai-declaration.md/en/0.1.2)): `<highest level>`
<!-- ai-declaration:end -->
| 阶段 | 级别 | AI 参与度 | 决策记录 |
|:--|:--|--:|:--|
| <stage English name> <stage Chinese name> | <level English name> <level Chinese name> | <AI involvement> | <description> |
```
- Write the level in bold and underlined; omit the autonomy value for n/a 暂不适用.
- The badge level is the level with the most AI involvement among the stage rows; ignore rows with n/a 暂不适用. This follows the [specification's rule](https://ai-declaration.md) that the global level is the highest declared level.
- Keep the `ai-declaration` markers so an existing declaration can be replaced in place without duplicating it.
- Write the description in the PR description's language, in one or two sentences of at most 80 Chinese characters or 40 English words. State who made which decisions: name one or two representative decisions, and give only a count for the rest.
- State the description as settled fact, without hedges such as "not seen in the session" or "pending author confirmation".
- Refer to people as the author or a reviewer, never by username. Leave out judgments, session times, session ids, direct quotes, and the running or handling of checks and CI (local skips, hooks, reruns, unrelated failures).
### Update timing
- When you create the PR, the review line reads `- <ins>**n/a 暂不适用**</ins> · review 评审处理 — ` followed by a statement, in the description's language, that no review comments have been handled yet.
- When the description is updated after the PR exists, rewrite the affected lines so that they cover all work so far, including earlier stages whose decisions or human share the later work changed; do not append a history of changes.
- The review line records only handled review comments; it omits approvals, merge timing, and unhandled comments.
- When you create the PR, the review row reads `| review 评审处理 | n/a 暂不适用 | — | ` followed by a statement, in the description's language, that no review comments have been handled yet.
- When the description is updated after the PR exists, rewrite the affected rows so that they cover all work so far, including earlier stages whose decisions or human share the later work changed; do not append a history of changes.
- When a stage's level changes, update its row; when the highest level changes, update the badge URL and the declaration line with it.
- The review row records only handled review comments; it omits approvals, merge timing, and unhandled comments.
- A review round ends when its fixes are pushed and its comments answered. Comments that arrive after merge do not trigger an update.
### Example
@@ -158,15 +180,25 @@ Levels and Agent autonomy: <ins>**hint 人工独立**</ins> 0–10%, <ins>**assi
Filled in from the session and review records of #6013:
```markdown
- <ins>**pair 人工引导**</ins> 40% · breakdown 问题拆解 — The author identified two process-specific values that do not belong in the system prompt and pointed to the custom model route; the Agent traced the root cause.
- <ins>**assist 人工主导**</ins> 20% · design 方案设计 — The author supplied the design structure: runtime context, frozen legacy sections for old sessions, and a separate package; the Agent filled in the projection matching.
- <ins>**pair 人工引导**</ins> 40% · testing 验收测试 — The author required two scenarios: restoring an old session after upgrade, and changing ports after upgrade; the Agent wrote the cases and snapshots.
- <ins>**pair 人工引导**</ins> 35% · implementation 技术实现 — The Agent wrote the code and the Agent Note; the author required one revert and specified 3 approaches, such as getOrigin() instead of an origin property.
- <ins>**pair 人工引导**</ins> 40% · review 评审处理 — The author decided each bot comment, such as no upgrade guide and keeping a devDependency; the Agent fixed and replied.
## AI Usage
<!-- ai-declaration:start -->
[![AI-DECLARATION: pair](https://img.shields.io/badge/䷼%20AI--DECLARATION-pair-ffedd5?labelColor=ffedd5)](https://ai-declaration.md)
**AI declaration** ([v0.1.2](https://ai-declaration.md/en/0.1.2)): `pair`
<!-- ai-declaration:end -->
| 阶段 | 级别 | AI 参与度 | 决策记录 |
|:--|:--|--:|:--|
| breakdown 问题拆解 | pair 人工引导 | 40% | The author identified two process-specific values that do not belong in the system prompt and pointed to the custom model route; the Agent traced the root cause. |
| design 方案设计 | assist 人工主导 | 20% | The author supplied the design structure: runtime context, frozen legacy sections for old sessions, and a separate package; the Agent filled in the projection matching. |
| testing 验收测试 | pair 人工引导 | 40% | The author required two scenarios: restoring an old session after upgrade, and changing ports after upgrade; the Agent wrote the cases and snapshots. |
| implementation 技术实现 | pair 人工引导 | 35% | The Agent wrote the code and the Agent Note; the author required one revert and specified 3 approaches, such as getOrigin() instead of an origin property. |
| review 评审处理 | pair 人工引导 | 40% | The author decided each bot comment, such as no upgrade guide and keeping a devDependency; the Agent fixed and replied. |
```
## Creating and updating the description
1. Write the description to a file that git ignores, such as `.artifacts/pr/body.md`. Create the PR with `gh pr create --body-file <file>`, or update it with `gh pr edit <number> --body-file <file>`; when the description embeds a GIF, follow [record-browser-gif](../record-browser-gif/SKILL.md) and use `--attach`.
2. Before an update, save the current description with `gh pr view <number> --json body --jq .body`, and change only the sections that need it: keep content and media the author wrote, and remove statements that no longer match the code.
3. After writing, read the description back and confirm that all four sections are present and that Human and Agent Roles matches the current code and review state.
2. Before an update, save the current description with `gh pr view <number> --json body --jq .body`, and change only the sections that need it: keep content and media the author wrote, replace only the contents of an existing `ai-declaration` block, and remove statements that no longer match the code. When the description still has the old `## Human and Agent Roles` heading, replace that heading and its level line with `## AI Usage` and the block; keep only one AI Usage section.
3. After writing, read the description back and confirm that all four sections are present, that AI Usage matches the current code and review state, and that no old `## Human and Agent Roles` section remains.
+81 -10
View File
@@ -215,7 +215,7 @@ test('keeps the pull request template aligned with the dsh-pr-description skill'
'## Motivation',
'## Changes',
'## Testing',
'## Human and Agent Roles',
'## AI Usage',
])
assert.doesNotMatch(source, /^### /m)
assert.match(source, /^## Motivation\n\n<!-- [^\n]*Fixes #N[^\n]* -->$/m, 'the template prompts for an Issue reference')
@@ -223,30 +223,101 @@ test('keeps the pull request template aligned with the dsh-pr-description skill'
assert.equal(source.match(/<details>/g)?.length, 1)
assert.equal(source.match(/<\/details>/g)?.length, 1)
const levelLine = /^Levels and Agent autonomy: .+$/m
assert.ok(skill.match(levelLine), 'the skill shows the role level line')
assert.equal(source.match(levelLine)?.[0], skill.match(levelLine)?.[0], 'the template level line matches the skill')
const stagesSection = skill.match(/^### Stages and criteria\n([\s\S]*?)(?=^### )/m)?.[1]
assert.ok(stagesSection, 'the skill has a Stages and criteria section')
const skillStages = [...stagesSection.matchAll(/^\*\*(.+?)\*\*:/gm)].map(([, stage]) => stage)
assert.ok(skillStages.length > 0, 'the skill lists role stages')
assert.ok(skillStages.length > 0, 'the skill lists stages')
for (const stage of skillStages) {
assert.match(stage, /^[a-z]+ [\u4e00-\u9fff]+$/, `stage name pairs one English word with a Chinese name: ${stage}`)
}
const reviewStage = 'review 评审处理'
assert.equal(skillStages.at(-1), reviewStage, 'review is the last stage')
const skillProse = skill.replace(/```[\s\S]*?```/g, '')
assert.match(skillProse, /^## AI Usage$/m, 'the skill names the section AI Usage')
assert.match(skillProse, /^### Section format$/m, 'the skill has a Section format section')
assert.ok(skill.includes('| Level | Meaning | AI involvement |'), 'the skill defines AI involvement')
const badgeSection = skill.match(/^### Badge URLs\n([\s\S]*?)(?=^### )/m)?.[1]
assert.ok(badgeSection, 'the skill has a Badge URLs section')
for (const [level, color] of [
['hint', 'ecfccb'],
['assist', 'fef9c3'],
['pair', 'ffedd5'],
['copilot', 'fee2e2'],
['auto', 'ede9fe'],
]) {
assert.ok(
badgeSection.includes(`https://img.shields.io/badge/䷼%20AI--DECLARATION-${level}-${color}?labelColor=${color}`),
`the skill has the ${level} badge URL`,
)
}
const specVersion = skill.match(/pins specification version (\d+\.\d+\.\d+)/)?.[1]
assert.ok(specVersion, 'the skill pins the specification version')
const versionLink = `[v${specVersion}](https://ai-declaration.md/en/${specVersion})`
assert.ok(skill.includes(versionLink), 'the skill links the pinned specification version')
assert.ok(source.includes(versionLink), 'the template links the pinned specification version')
const sectionFormat = skill.match(/^### Section format\n[\s\S]*?```markdown\n([\s\S]*?)```/m)?.[1]
assert.ok(sectionFormat, 'the skill shows the section format')
const example = skill.match(/^### Example\n[\s\S]*?```markdown\n([\s\S]*?)```/m)?.[1]
assert.ok(example, 'the skill has an example')
for (const [label, markdown] of [
['section format', sectionFormat],
['example', example],
]) {
assert.match(markdown, /^## AI Usage$/m, `the ${label} uses the AI Usage heading`)
assert.match(markdown, /^<!-- ai-declaration:start -->$/m, `the ${label} starts the declaration block`)
assert.match(markdown, /^<!-- ai-declaration:end -->$/m, `the ${label} ends the declaration block`)
assert.ok(markdown.includes('| 阶段 | 级别 | AI 参与度 | 决策记录 |'), `the ${label} shows the stage table header`)
assert.ok(markdown.includes(versionLink), `the ${label} uses the pinned specification version`)
}
const exampleRows = [...example.matchAll(/^\| .+? \| ([a-z]+) [^|]+ \| (\d+)% \| .+$/gm)]
assert.ok(exampleRows.length > 0, 'the example has stage rows')
const levelOrder = ['hint', 'assist', 'pair', 'copilot', 'auto']
const highestExampleLevel = exampleRows
.map(([, level]) => level)
.reduce((highest, level) => (levelOrder.indexOf(level) > levelOrder.indexOf(highest) ? level : highest))
assert.ok(example.includes(`AI-DECLARATION: ${highestExampleLevel}`), 'the example badge shows the highest level')
const declarationBlock = source.match(/^<!-- ai-declaration:start -->\n([\s\S]*?)\n<!-- ai-declaration:end -->$/m)?.[1]
assert.ok(declarationBlock, 'the template has one ordered AI declaration block')
assert.equal(source.match(/<!-- ai-declaration:start -->/g)?.length, 1)
assert.equal(source.match(/<!-- ai-declaration:end -->/g)?.length, 1)
assert.ok(
declarationBlock.includes(
'[![AI-DECLARATION: 待填级别](https://img.shields.io/badge/䷼%20AI--DECLARATION-待填级别-e5e7eb?labelColor=e5e7eb)](https://ai-declaration.md)',
),
'the block has the placeholder badge link',
)
assert.ok(
declarationBlock.includes(`**AI declaration** (${versionLink}): \`待填级别\``),
'the block pins the declaration',
)
const usageSection = source.slice(source.indexOf('## AI Usage'))
assert.ok(usageSection.includes('<!-- ai-declaration:start -->'), 'the AI declaration block is in AI Usage')
assert.ok(usageSection.includes('| 阶段 | 级别 | AI 参与度 | 决策记录 |'), 'the stage table is in AI Usage')
assert.ok(source.includes('|:--|:--|--:|:--|'), 'the template right-aligns AI involvement')
assert.deepEqual(
[...source.matchAll(/^- <ins>\*\*(?:待填级别\*\*<\/ins> \?%|n\/a 暂不适用\*\*<\/ins>) · (.+?) — .+$/gm)].map(([, stage]) => stage),
[...source.matchAll(/^\| (.+?) \| (?:待填级别|n\/a 暂不适用) \| (?:\?%|—) \| .+$/gm)].map(([, stage]) => stage),
skillStages,
'the template has one line per skill stage, in skill order',
'the template has one row per skill stage, in skill order',
)
for (const stage of skillStages.slice(0, -1)) {
assert.ok(source.includes(`- <ins>**待填级别**</ins> ?% · ${stage} — 待填说明\n`), `${stage} has a placeholder line`)
assert.ok(source.includes(`| ${stage} | 待填级别 | ?% | 待填说明 |`), `${stage} has a placeholder row`)
}
assert.ok(
source.includes(`- <ins>**n/a 暂不适用**</ins> · ${reviewStage} — 尚无已处理的评审意见。\n`),
'the review line starts as n/a, as the skill requires at PR creation',
source.includes(`| ${reviewStage} | n/a 暂不适用 | — | 尚无已处理的评审意见。 |`),
'the review row starts as n/a, as the skill requires at PR creation',
)
for (const [label, text] of [
['template', source],
['skill', skill],
]) {
assert.doesNotMatch(text, /^## Human and Agent Roles$/m, `${label} has no old heading`)
assert.doesNotMatch(text, /^Levels and Agent autonomy:/m, `${label} has no old level line`)
assert.doesNotMatch(text, /<ins>/, `${label} has no old underline markup`)
}
})
test('ignores Issue title, body presentation, and assignee ownership', () => {
+13 -7
View File
@@ -17,12 +17,18 @@
</details>
## Human and Agent Roles
## AI Usage
Levels and Agent autonomy: <ins>**hint 人工独立**</ins> 0–10%, <ins>**assist 人工主导**</ins> 15–25%, <ins>**pair 人工引导**</ins> 30–50%, <ins>**copilot 人工把关**</ins> 55–85%, <ins>**auto 模型自主**</ins> 90–100%; <ins>**n/a 暂不适用**</ins> marks a stage that does not exist or has not happened yet.
<!-- ai-declaration:start -->
[![AI-DECLARATION: 待填级别](https://img.shields.io/badge/䷼%20AI--DECLARATION-待填级别-e5e7eb?labelColor=e5e7eb)](https://ai-declaration.md)
- <ins>**待填级别**</ins> ?% · breakdown 问题拆解 — 待填说明
- <ins>**待填级别**</ins> ?% · design 方案设计 — 待填说明
- <ins>**待填级别**</ins> ?% · testing 验收测试 — 待填说明
- <ins>**待填级别**</ins> ?% · implementation 技术实现 — 待填说明
- <ins>**n/a 暂不适用**</ins> · review 评审处理 — 尚无已处理的评审意见。
**AI declaration** ([v0.1.2](https://ai-declaration.md/en/0.1.2)): `待填级别`
<!-- ai-declaration:end -->
| 阶段 | 级别 | AI 参与度 | 决策记录 |
|:--|:--|--:|:--|
| breakdown 问题拆解 | 待填级别 | ?% | 待填说明 |
| design 方案设计 | 待填级别 | ?% | 待填说明 |
| testing 验收测试 | 待填级别 | ?% | 待填说明 |
| implementation 技术实现 | 待填级别 | ?% | 待填说明 |
| review 评审处理 | n/a 暂不适用 | — | 尚无已处理的评审意见。 |
@@ -9,8 +9,8 @@
en: ed2a83d06f6789ef
zh: e5c1e26486dfefb0
/plugin-market-index-generator/what-gets-listed:
en: 7457f51ab0e41cbe
zh: 6c690391aaa7cf0a
en: 4a01101246724006
zh: 8455f6bc4552b787
/plugin-market-index-generator/caching-and-repeated-runs:
en: a62c9bf12310c33c
zh: 60aa01288f46133c
+3 -3
View File
@@ -9,7 +9,7 @@ A reference generator for [plugin market index](../../.agents/notes/implemented/
Write a source list, one entry per line. Blank lines and lines starting with `#` are ignored.
```text
# npm packages: every non-deprecated bundle version is listed
# npm packages: every bundle version that is neither deprecated nor private is listed
@scope/dsh-example
dsh-another-plugin
# Git repositories: release tags and the default branch are listed
@@ -43,9 +43,9 @@ The exit status is 0 when every source was read, 1 when a source could not be re
## What gets listed
Each package becomes one plugin with one or more versions. A version is listed only when its `package.json` declares `dsh.bundle` with `patch` set to a file path or a list of file paths.
Each package becomes one plugin with one or more versions. A version is listed only when its `package.json` declares `dsh.bundle` with `patch` set to a file path or a list of file paths, and does not set `"private": true`. A Git package whose `package.json` on the default branch sets `"private": true` is not listed, and neither are its tagged versions.
- **npm package**: every version in the registry document that is not deprecated, declares a bundle, and has a publish time. Versions come from the registry document alone; only the tarball of the highest listed version is downloaded, for its display fields. Its `package.json` must name the requested package and version.
- **npm package**: every version in the registry document that is neither deprecated nor private, declares a bundle, and has a publish time. Versions come from the registry document alone; only the tarball of the highest listed version is downloaded, for its display fields. Its `package.json` must name the requested package and version.
- **Git repository**: bundle packages are found on the default branch, in this order:
1. the root `package.json` declares `dsh.bundle`: the root package only;
2. `package.json` `workspaces` or `pnpm-workspace.yaml` `packages` match packages that declare `dsh.bundle`: those packages. Each glob segment is a literal name, a name with `*`, or `**`; negated globs are ignored, and a glob that matches nothing is reported;
+3 -3
View File
@@ -9,7 +9,7 @@
编写来源列表,每行一个条目。生成器忽略空行和以 `#` 开头的行。
```text
# npm packages: every non-deprecated bundle version is listed
# npm packages: every bundle version that is neither deprecated nor private is listed
@scope/dsh-example
dsh-another-plugin
# Git repositories: release tags and the default branch are listed
@@ -43,9 +43,9 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start \
## 列出的内容
每个包成为一个插件,带有一个或多个版本。只有 `package.json` 声明了 `dsh.bundle`、且其 `patch` 是一个文件路径或文件路径列表的版本才会列出。
每个包成为一个插件,带有一个或多个版本。只有 `package.json` 声明了 `dsh.bundle`、其 `patch` 是一个文件路径或文件路径列表、且没有设置 `"private": true` 的版本才会列出。默认分支上的 `package.json` 设置了 `"private": true` 的 Git 包不会列出,它在各个标签上的版本也不列出。
- **npm 包**:注册表文档中未弃用、声明了组合包(bundle)且有发布时间的每个版本。版本只取自注册表文档;生成器只下载最高列出版本的 tarball,用于读取展示字段。该 tarball 的 `package.json` 必须与所请求的包名和版本一致。
- **npm 包**:注册表文档中未弃用、未设为私有、声明了组合包(bundle)且有发布时间的每个版本。版本只取自注册表文档;生成器只下载最高列出版本的 tarball,用于读取展示字段。该 tarball 的 `package.json` 必须与所请求的包名和版本一致。
- **Git 仓库**:生成器按以下顺序在默认分支上查找组合包:
1. 根 `package.json` 声明了 `dsh.bundle`:仅使用根包;
2. `package.json` 的 `workspaces` 或 `pnpm-workspace.yaml` 的 `packages` 匹配到声明了 `dsh.bundle` 的包:使用这些包。每个 glob 段可以是字面名称、含 `*` 的名称或 `**`;生成器忽略否定 glob,并报告未匹配任何内容的 glob;
+4 -4
View File
@@ -3,10 +3,10 @@
*
* Usage: plugin-market-generator --input <list> --output <index.json> --title <text> [options]
*
* The list holds one npm package name or https Git repository URL per line. The generator lists every non-deprecated
* npm bundle version, and the versions at Git release tags and the default branch, never runs package code, and writes
* a validated format-1 index. Unchanged releases and commits are read from the cache directory, and the index file is
* rewritten only when its content changes.
* The list holds one npm package name or https Git repository URL per line. The generator lists every npm bundle
* version that is neither deprecated nor private, and the versions at Git release tags and the default branch of
* packages that are not private, never runs package code, and writes a validated format-1 index. Unchanged releases
* and commits are read from the cache directory, and the index file is rewritten only when its content changes.
*/
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
+13 -10
View File
@@ -6,7 +6,7 @@ import semver from 'semver'
import { cacheKey, gitRefs, npmPackument, npmTarball, withGitObjects, type FetchLimits, type GitObjects, type GitTransports } from './fetchers.ts'
import { readJsonFile, writeIfChanged, type PackageFiles } from './files.ts'
import { GENERATED_ICON, readDisplayMetadata, type Display, type IconFile } from './metadata.ts'
import { dshRange, findBundlePackages, isBundle, readManifest, type Manifest } from './packages.ts'
import { dshRange, findBundlePackages, isBundle, isPrivate, readManifest, type Manifest } from './packages.ts'
import type { Source } from './sources.ts'
/** One generated version, in the field order of the format. */
@@ -59,7 +59,7 @@ interface Release {
/** Base URL only used to validate relative icon references. */
const VALIDATION_URL = 'https://index.invalid/index.json'
/** Part of every cache key; change it when the way entries are derived changes, so cached results are rebuilt. */
const CACHE_VERSION = 7
const CACHE_VERSION = 8
/** Why the index format rejects these plugins, or undefined when it accepts them. */
function invalid(plugins: unknown): string | undefined {
@@ -154,8 +154,8 @@ async function readDisplay(
}
/**
* Read every non-deprecated bundle version from the registry document, and the display fields from the tarball of the
* highest one, through a cache keyed by the listed versions.
* Read every bundle version from the registry document that is neither deprecated nor private, and the display fields
* from the tarball of the highest one, through a cache keyed by the listed versions.
*/
async function readNpm(source: Extract<Source, { kind: 'npm' }>, options: GenerateOptions, cache: Cache): Promise<GeneratedEntry[]> {
const packument = await npmPackument(options.registry, source.name, options.limits)
@@ -164,12 +164,12 @@ async function readNpm(source: Extract<Source, { kind: 'npm' }>, options: Genera
const releases = Object.entries(packument.versions ?? {}).flatMap(([version, manifest]) => {
const published = packument.time?.[version]
const deprecated = typeof manifest.deprecated === 'string' && manifest.deprecated !== ''
if (deprecated || !isBundle(manifest) || typeof published !== 'string') return []
if (deprecated || isPrivate(manifest) || !isBundle(manifest) || typeof published !== 'string') return []
return releaseOf(source.name, manifest, version, Math.floor(Date.parse(published) / 1000), npmSource, warn) ?? []
})
const highest = entryOf(source.name, releases).versions[0]?.version
if (highest === undefined) {
warn('no published version declares dsh.bundle')
warn('no published version that is neither deprecated nor private declares dsh.bundle')
return []
}
const file = cache.file('npm', options.registry, options.inlineIconBytes, source.name, releases.map(release => release.version.version).sort())
@@ -261,7 +261,7 @@ async function readGit(source: Extract<Source, { kind: 'git' }>, options: Genera
const files = await objects.tree(commit, path)
const manifest = await readManifest(files, '')
const gitSource = { type: 'git', url: source.url, commit, ...path === '' ? {} : { path } }
release = isBundle(manifest) && typeof manifest?.name === 'string' && typeof manifest.version === 'string'
release = isBundle(manifest) && !isPrivate(manifest) && typeof manifest?.name === 'string' && typeof manifest.version === 'string'
? releaseOf(manifest.name, manifest, manifest.version, await objects.committed(commit), gitSource, warn) ?? null : null
} catch (error) {
warn(`${commit}:${path} skipped: ${(error as Error).message}`)
@@ -284,16 +284,19 @@ async function readGit(source: Extract<Source, { kind: 'git' }>, options: Genera
}
}
/** Names of the bundle packages on the default branch, by package directory. */
/** Names of the bundle packages on the default branch that are not private, by package directory. */
async function headPackages(files: PackageFiles, warn: (message: string) => void): Promise<{ path: string; name: string }[]> {
const paths = await findBundlePackages(files, warn)
if (paths.length === 0) warn('no package declares dsh.bundle')
const packages: { path: string; name: string }[] = []
let privates = 0
for (const path of paths) {
const name = (await readManifest(files.within(path), ''))?.name
if (typeof name === 'string') packages.push({ path, name })
const manifest = await readManifest(files.within(path), '')
if (isPrivate(manifest)) privates++
else if (typeof manifest?.name === 'string') packages.push({ path, name: manifest.name })
else warn(`${posix.join(path, 'package.json')} has no name`)
}
if (privates > 0 && privates === paths.length) warn('every package that declares dsh.bundle is private')
return packages
}
@@ -15,6 +15,7 @@ export interface Manifest {
readonly repository?: unknown
readonly keywords?: unknown
readonly deprecated?: unknown
readonly private?: unknown
readonly workspaces?: unknown
readonly exports?: unknown
readonly engines?: unknown
@@ -63,6 +64,15 @@ export function isBundle(manifest: Manifest | undefined): boolean {
return typeof bundle.patch === 'string' || (Array.isArray(bundle.patch) && bundle.patch.every(file => typeof file === 'string'))
}
/**
* Whether a manifest sets `"private": true`, which keeps its package or version out of an index.
* @param manifest - parsed manifest, or undefined for a directory without one.
* @returns true only when `private` is the boolean `true`.
*/
export function isPrivate(manifest: Manifest | undefined): boolean {
return manifest?.private === true
}
/** Whether installation reads `range` as a SemVer range; an empty range and other `workspace:` ranges never match. */
const valid = (range: string): boolean => range !== '' && !range.startsWith('workspace:') && semver.validRange(range) !== null
const invalidRange = (range: string): Error => new Error(`DSH range ${JSON.stringify(range)} is not a valid SemVer range`)
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { PackageFiles } from '../src/files.ts'
import { readDisplayMetadata } from '../src/metadata.ts'
import { dshRange, findBundlePackages, isBundle, readManifest, type Manifest } from '../src/packages.ts'
import { dshRange, findBundlePackages, isBundle, isPrivate, readManifest, type Manifest } from '../src/packages.ts'
import { parseSources } from '../src/sources.ts'
/** An in-memory tree from relative path → file contents. */
@@ -54,6 +54,11 @@ describe('isBundle and dshRange', () => {
expect(isBundle(undefined)).toBe(false)
})
it('reads only the boolean true as private', () => {
expect([true, 'true', false, 1, undefined].map(value => isPrivate({ private: value }))).toEqual([true, false, false, false, false])
expect(isPrivate(undefined)).toBe(false)
})
it('copies engines.dsh, else joins DSH peer ranges, and refuses ranges installation rejects', () => {
expect(dshRange({ engines: { dsh: '>=0.2.0' }, peerDependencies: { '@deepseek-ai/dsh': '1' } })).toBe('>=0.2.0')
expect(dshRange({ peerDependencies: { react: '18', '@deepseek-ai/dsh-agent': 'workspace:*', '@deepseek-ai/dsh': '*' } })).toBeUndefined()
@@ -87,12 +87,13 @@ function options(registryUrl: string, cacheDir: string, lines: string[], warning
}
}
it('lists every non-deprecated bundle version from the registry document and reads display fields from the highest tarball', async () => {
it('lists every bundle version from the registry document that is neither deprecated nor private and reads display fields from the highest tarball', async () => {
const packages: Packages = {
'dsh-a': {
'0.9.0': { time: '2026-01-01T00:00:00.000Z' },
'1.0.0': { time: '2026-02-01T00:00:00.000Z', manifest: { ...BUNDLE, keywords: ['old'], engines: { dsh: '>=0.2.0' }, peerDependencies: { '@deepseek-ai/dsh': '9' } } },
'1.1.0': { time: '2026-03-01T00:00:00.000Z', manifest: { ...BUNDLE, deprecated: 'broken' } },
'1.1.5': { time: '2026-03-15T00:00:00.000Z', manifest: { ...BUNDLE, private: true } },
'1.2.0': {
time: '2026-04-01T00:00:00.000Z',
manifest: {
@@ -117,6 +118,7 @@ it('lists every non-deprecated bundle version from the registry document and rea
},
'dsh-b': { '1.0.0': { time: '2026-06-02T00:00:00.000Z', manifest: { ...BUNDLE, author: 'Bob' }, tarball: await pack({ 'package.json': bundleManifest('dsh-b', '1.0.0') }) } },
'plain-lib': { '1.0.0': { time: '2026-06-01T00:00:00.000Z' } },
'dsh-private': { '1.0.0': { time: '2026-06-01T00:00:00.000Z', manifest: { ...BUNDLE, private: true } } },
'dsh-imposter': { '1.0.0': { time: '2026-06-01T00:00:00.000Z', manifest: BUNDLE, tarball: await pack({ 'package.json': bundleManifest('dsh-other', '1.0.0') }) } },
'dsh-tampered': { '1.0.0': { time: '2026-06-01T00:00:00.000Z', manifest: BUNDLE, tarball: await pack({ 'package.json': bundleManifest('dsh-tampered', '1.0.0') }), integrity: 'sha512-AAAA' } },
'dsh-unsigned': { '1.0.0': { time: '2026-06-01T00:00:00.000Z', manifest: BUNDLE } },
@@ -129,7 +131,7 @@ it('lists every non-deprecated bundle version from the registry document and rea
const server = await registry(packages)
const cacheDir = scratch()
const warnings: string[] = []
const lines = ['dsh-a', 'dsh-b', 'plain-lib', 'dsh-imposter', 'dsh-tampered', 'dsh-unsigned', 'dsh-huge', 'dsh-bomb', 'dsh-no-tarball', 'dsh-unpublished', 'dsh-missing']
const lines = ['dsh-a', 'dsh-b', 'plain-lib', 'dsh-private', 'dsh-imposter', 'dsh-tampered', 'dsh-unsigned', 'dsh-huge', 'dsh-bomb', 'dsh-no-tarball', 'dsh-unpublished', 'dsh-missing']
const first = await generate(options(server.url, cacheDir, lines, warnings))
const npm = { type: 'npm', registry: server.url }
expect(first.index).toMatchObject({ format: 1, title: 'Test Market', description: 'For tests' })
@@ -160,8 +162,9 @@ it('lists every non-deprecated bundle version from the registry document and rea
expect(warnings).toEqual(expect.arrayContaining([
'dsh-a: [email protected] skipped: DSH range "workspace:>=1" is not a valid SemVer range',
'[email protected]: locale file locale/x_y.json ignored: its name is not a language id',
'plain-lib: no published version declares dsh.bundle',
'dsh-unpublished: no published version declares dsh.bundle',
'plain-lib: no published version that is neither deprecated nor private declares dsh.bundle',
'dsh-private: no published version that is neither deprecated nor private declares dsh.bundle',
'dsh-unpublished: no published version that is neither deprecated nor private declares dsh.bundle',
]))
// Unchanged version lists come from the cache; a failing source keeps its last result for the same registry only.
+13 -2
View File
@@ -76,6 +76,7 @@ it('lists Git versions from release tags and the default branch by reading objec
'.gitattributes': '* filter=marker\n',
'packages/a/package.json': bundle('@s/a', '1.0.0'),
'packages/b/package.json': bundle('@s/b', '0.9.0'),
'packages/e/package.json': bundle('@s/e', '1.0.0'),
})
const first = repo.commit(1790000000)
repo.git(['tag', 'v1.0.0'])
@@ -96,6 +97,8 @@ it('lists Git versions from release tags and the default branch by reading objec
'packages/a/icon.svg': '<svg/>',
'packages/b/package.json': bundle('@s/b', '2.0.0', { keywords: ['head'] }),
'packages/d/package.json': JSON.stringify({ ...JSON.parse(bundle('@s/d', '0.1.0')), peerDependencies: { '@deepseek-ai/dsh': 'workspace:>=1' } }),
// A package that turns private on the default branch is not listed, not even at its earlier public tag.
'packages/e/package.json': bundle('@s/e', '1.0.1', { private: true }),
})
const head = repo.commit(1790000300)
repo.git(['tag', 'v2.0.0'])
@@ -131,7 +134,7 @@ it('lists Git versions from release tags and the default branch by reading objec
expect(updated.index.plugins[1]).not.toHaveProperty('metadata')
})
it('reads a root package, reports repositories without one, and refuses transports that are not allowed', GIT_TEST, async () => {
it('reads a root package, reports repositories without a public one, and refuses transports that are not allowed', GIT_TEST, async () => {
const repo = repository()
gitHome(repo.dir)
const warnings: string[] = []
@@ -149,12 +152,20 @@ it('reads a root package, reports repositories without one, and refuses transpor
repo.write({ 'package.json': JSON.stringify({ version: '1.0.0', dsh: { bundle: { patch: 'p.yml' } } }) })
repo.commit(1790000100)
expect(await run()).toEqual([[], []])
// A private package is not listed, and a release tag on a private manifest lists no version.
repo.write({ 'package.json': bundle('dsh-root', '0.9.0', { private: true }) })
repo.commit(1790000150)
repo.git(['tag', 'v0.9.0'])
expect(await run()).toEqual([[], []])
repo.write({ 'package.json': bundle('dsh-root', '1.0.0') })
const commit = repo.commit(1790000200)
expect(await run()).toEqual([[{
name: 'dsh-root', title: 'dsh-root', versions: [{ version: '1.0.0', publishTimestamp: 1790000200, source: { type: 'git', url: REPOSITORY, commit } }],
}], []])
expect(warnings).toEqual([`${REPOSITORY}: ${REPOSITORY}: no HEAD commit`, expect.stringContaining('not allowed') as string, `${REPOSITORY}: no package declares dsh.bundle`, `${REPOSITORY}: package.json has no name`])
expect(warnings).toEqual([
`${REPOSITORY}: ${REPOSITORY}: no HEAD commit`, expect.stringContaining('not allowed') as string, `${REPOSITORY}: no package declares dsh.bundle`,
`${REPOSITORY}: package.json has no name`, `${REPOSITORY}: every package that declares dsh.bundle is private`,
])
})
it('skips a release tag it cannot read, refuses oversized manifests, and ignores Git overrides from the environment', GIT_TEST, async () => {
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md
/:
en: a1e092dfacd2aed8
zh: 838448e5bf0f9d93
/timer-wrapper-return-types-and-disposal:
en: 8b8de9b0fc2e3fc3
zh: ffa20b7ffa7540ef
/timer-wrapper-return-types-and-disposal/change:
en: 3d607c2a17936a5a
zh: 1c90b9bce38e4186
/timer-wrapper-return-types-and-disposal/migration:
en: 569f590747c713c5
zh: f38f7af763209b94
@@ -0,0 +1,24 @@
---
kind: upgrade-guide
description: "Timer wrappers return void in TypeScript and ignore calls after disposal."
---
# Timer wrapper return types and disposal
English | [中文](guide.zh.md)
## Change
The functions returned by `ctx.throttle()` and `ctx.debounce()` now declare a `void` return type. They previously inherited the callback's return type, although they already returned `undefined` at runtime. TypeScript code that uses the wrapper's return value or assigns it to the callback's type may need updating. Host and browser use the same `TimerService` implementation.
Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later wrapper calls inert. A disposed throttle wrapper previously could still execute a leading call; debounce wrappers already ignored calls after disposal.
The `@deepseek-ai/dsh-cordis-client-runner/client` entry no longer exports `ClientTimerService`. The runner installs the shared `TimerService` from `@deepseek-ai/cordis-plugin-timer`.
## Migration
1. Let TypeScript infer the wrapper type, or declare its call signature with a `void` return type while retaining `.dispose()` where needed.
2. Consume callback results inside the callback. Awaiting the wrapper does not wait for an asynchronous callback to finish.
3. Move required work before disposal, or create a new wrapper in an active plugin.
4. Remove imports of `ClientTimerService` from the runner. Consumer plugins declare `inject: ['timer']` and use `ctx.timer`; code that installs the service imports `TimerService` from `@deepseek-ai/cordis-plugin-timer`.
5. Run your extension's type check and verify that calls after disposal neither invoke the callback nor schedule work.
@@ -0,0 +1,24 @@
---
kind: upgrade-guide
description: "Timer 包装函数的 TypeScript 返回类型为 void,释放后忽略后续调用。"
---
# Timer 包装函数的返回类型与资源释放
[English](guide.md) | 中文
## 变更
`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。Host 与浏览器使用同一份 `TimerService` 实现。
调用 `.dispose()` 或释放所属 fiber 会取消待执行的工作,并使后续包装函数调用不再执行。此前已释放的 throttle 包装函数仍可能执行前沿调用;debounce 包装函数在释放后已经会忽略调用。
`@deepseek-ai/dsh-cordis-client-runner/client` 入口不再导出 `ClientTimerService`。runner 安装的是 `@deepseek-ai/cordis-plugin-timer` 中的共享 `TimerService`。
## 迁移
1. 让 TypeScript 推断包装函数的类型,或将其调用签名声明为返回 `void`,并按需保留 `.dispose()`。
2. 在回调内部使用回调结果。对包装函数使用 await 不会等待异步回调完成。
3. 将必须执行的工作移到资源释放之前,或在活动插件中创建新的包装函数。
4. 移除从 runner 导入 `ClientTimerService` 的代码。消费插件声明 `inject: ['timer']` 并使用 `ctx.timer`;负责安装服务的代码从 `@deepseek-ai/cordis-plugin-timer` 导入 `TimerService`。
5. 运行扩展的类型检查,并验证释放后的调用既不会执行回调,也不会安排工作。
+2 -2
View File
@@ -21,8 +21,8 @@
en: b85b82b22c1e14b8
zh: 920c0d6f20956c07
/plugin-markets/generate-an-index-with-the-reference-generator:
en: 57a48912c00e1c85
zh: 69f2934eed0fbadf
en: 6501406f530ffe33
zh: 700acae3f466c985
/plugin-markets/index-format-reference:
en: d058d12c27433f64
zh: f43b85e4934817f3
+1 -1
View File
@@ -97,7 +97,7 @@ Run it from a DSH checkout:
pnpm --filter @deepseek-ai/dsh-plugin-market-generator start --input plugins.txt --output site/index.json --title "Team plugins"
```
For an npm package, the index lists every version that is not deprecated and declares `dsh.bundle`. For a Git repository, it finds the bundle packages on the default branch and lists their versions at `vX.Y.Z`, `X.Y.Z`, and `<name>@X.Y.Z` tags, plus each package's version on the default branch. Titles, descriptions, icons, authors, and homepages come from each plugin's highest version. Paths are relative to the directory where you run pnpm.
For an npm package, the index lists every version that is neither deprecated nor private and declares `dsh.bundle`. For a Git repository, it finds the bundle packages on the default branch whose `package.json` does not set `"private": true` and lists their versions at `vX.Y.Z`, `X.Y.Z`, and `<name>@X.Y.Z` tags, plus each package's version on the default branch. Titles, descriptions, icons, authors, and homepages come from each plugin's highest version. Paths are relative to the directory where you run pnpm.
The generator only downloads and reads files; it never installs packages or runs their code. It keeps a cache in `.plugin-market-cache` next to the list, so a repeated run downloads only new versions and commits. Publish the output directory, `index.json` together with its `icons/` directory, as described above.
+1 -1
View File
@@ -97,7 +97,7 @@ https://gitlab.example.com/team/dsh-plugins
pnpm --filter @deepseek-ai/dsh-plugin-market-generator start --input plugins.txt --output site/index.json --title "Team plugins"
```
对于 npm 包,索引列出所有未弃用且声明了 `dsh.bundle` 的版本。对于 Git 仓库,生成器在默认分支上查找组合包,列出它们在 `vX.Y.Z`、`X.Y.Z` 和 `<name>@X.Y.Z` 标签处的版本,以及每个包在默认分支上的版本。标题、描述、图标、作者和主页取自每个插件的最高版本。路径相对于你运行 pnpm 的目录。
对于 npm 包,索引列出所有未弃用、未设为私有且声明了 `dsh.bundle` 的版本。对于 Git 仓库,生成器在默认分支上查找 `package.json` 没有设置 `"private": true` 的组合包,列出它们在 `vX.Y.Z`、`X.Y.Z` 和 `<name>@X.Y.Z` 标签处的版本,以及每个包在默认分支上的版本。标题、描述、图标、作者和主页取自每个插件的最高版本。路径相对于你运行 pnpm 的目录。
生成器只下载和读取文件,从不安装包或运行其代码。它在列表旁的 `.plugin-market-cache` 中保存缓存,因此再次运行时只下载新的版本和提交。按上文所述发布输出目录,即 `index.json` 及其 `icons/` 目录。
+1 -1
View File
@@ -74,7 +74,7 @@ export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|
* identity to share — the framework itself is a requested module-table row
* (external), while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
const VENDORED_LIBRARY = /^@deepseek-ai\/(?:(?:cosmokit|schemastery)(?:\/|$)|cordis-plugin-timer$)/
/** Generated descriptor/codec contribution with no shared runtime identity. */
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
@@ -33,8 +33,8 @@
en: df635b29eaef982c
zh: ddaa4bd98359bd30
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map:
en: cbd46fb5821cfcc7
zh: 392e5cace2db6194
en: 897f65050ac6c4eb
zh: c856e88f15150b9c
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs:
en: 33f14a7fff7835e6
zh: 53838ac8d4a79600
@@ -55,6 +55,8 @@ The browser half is built on one principle: a dynamic package must ride the same
### Source map
The entry installs the vendored Cordis `TimerService`, shared with the Host, in the runner's context. The browser bundle inlines Timer and uses the module table's Cordis instance. Timer calls belong to the calling plugin's fiber; disposing it cancels pending work.
| File | Role |
|---|---|
| [`src/client/index.ts`](src/client/index.ts) | Plugin entry: runner, orchestrator, inspect registry, forwarded-event subscription |
@@ -64,7 +66,6 @@ The browser half is built on one principle: a dynamic package must ride the same
| [`src/client/guard.ts`](src/client/guard.ts) | The whitelisting `ctx` façade for loaded browser halves |
| [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Providers and the pending-query router |
| [`src/client/providers.ts`](src/client/providers.ts) | First-party client Inspect Providers (slots, theme, events) |
| [`src/client/timer.ts`](src/client/timer.ts) | The client timer service dynamic packages inject |
### Inspecting slot inputs
@@ -55,6 +55,8 @@ kind: "package-reference"
### 源码地图
入口在 runner 的上下文中安装与 Host 共用的 vendored Cordis `TimerService`。浏览器包内联 Timer,并使用模块表中的 Cordis 实例。定时任务归调用插件的 fiber 所有,释放该 fiber 会取消尚未执行的任务。
| 文件 | 职责 |
|---|---|
| [`src/client/index.ts`](src/client/index.ts) | 插件入口:runner、编排器、inspect 注册表、转发事件订阅 |
@@ -64,7 +66,6 @@ kind: "package-reference"
| [`src/client/guard.ts`](src/client/guard.ts) | 已装载浏览器半收到的白名单 `ctx` façade |
| [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Provider 与待答查询路由器 |
| [`src/client/providers.ts`](src/client/providers.ts) | 第一方 client Inspect Provider(slots、theme、events) |
| [`src/client/timer.ts`](src/client/timer.ts) | 动态包注入的 client 定时器服务 |
### 查询槽位输入
@@ -47,6 +47,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
"@deepseek-ai/dsh-api-remotes": "workspace:*",
"@deepseek-ai/dsh-client-modules": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
@@ -319,12 +319,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [],
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
description: 'Return a throttled function whose timer is disposed with the current fiber.',
parameters: [],
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
description: 'Return a debounced function whose timer is disposed with the current fiber.',
parameters: [],
},
@@ -11,6 +11,7 @@
*/
import type { Context } from '@deepseek-ai/cordis'
import { TimerService } from '@deepseek-ai/cordis-plugin-timer'
import type {
ApprovalRequestId, CordisDynamicPluginId, DynamicCordisInvokeResult,
DynamicCordisInventoryRow,
@@ -26,7 +27,6 @@ import { DynamicCordisPackageRunner } from './runtime.ts'
import { CordisRunOrchestrator } from './orchestrator.ts'
import { ClientCordisInspectRegistry, provideClientCordisInspect } from './inspect-registry.ts'
import { clientInspectProviders } from './providers.ts'
import { provideClientTimer } from './timer.ts'
import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest } from './orchestrator.ts'
import type { CordisObservable, DynamicCordisRenderFailure } from './runtime.ts'
@@ -49,7 +49,6 @@ export { DynamicCordisStyles, evaluateClientHalf, isDynamicCordisPlugin } from '
export type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from './evaluator.ts'
export { dynamicCordisContext } from './guard.ts'
export type { DynamicCordisGuardEnv, DynamicCordisSlotLedgerRow } from './guard.ts'
export { ClientTimerService } from './timer.ts'
// Re-exported so consumers of the service face and the two events can name
// their subjects without reaching into the wire contract themselves.
export type {
@@ -186,7 +185,7 @@ export const inject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCo
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
provideClientTimer(ctx)
new TimerService(ctx)
const inspect = new ClientCordisInspectRegistry({
sync: async (providers) => {
const answered = await ctx.remote.dynamicCordisRunner.syncInspectManifest(providers)
@@ -1,216 +0,0 @@
/** Browser implementation of the Cordis timer Service. */
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
/*
* The browser Service preserves the vendored Host TimerService's erased callback tuples and arbitrary
* async-iterator return and rejection values, so narrowing these positions would change the public API.
*/
/* oxlint-disable typescript/no-explicit-any -- Exact Host TimerService API compatibility; see above. */
/* oxlint-disable typescript/no-unsafe-argument -- The erased callback tuples pass through unchanged. */
/* oxlint-disable typescript/no-unsafe-assignment -- The erased callback tuples pass through unchanged. */
/* oxlint-disable typescript/no-unsafe-member-access -- The returned wrapper retains its dispose property. */
/* oxlint-disable typescript/no-unsafe-return -- The erased generic return values pass through unchanged. */
/* oxlint-disable typescript/prefer-promise-reject-errors -- Async iterators preserve arbitrary throw reasons. */
declare module '@deepseek-ai/cordis' {
interface Context extends Pick<ClientTimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
/** Browser timer Service used by the mixed-in Context helpers. */
timer: ClientTimerService
}
}
type WithDispose<T> = T & { dispose: () => void }
// These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator
// return/rejection values must pass through without narrowing them to one caller's invocation.
/** Browser timer Service with the same public API as the Host Cordis TimerService. */
export class ClientTimerService extends Service {
/** Register the Service and mix its lifecycle-safe helpers onto Context. */
constructor(ctx: Context) {
super(ctx, 'timer')
ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
}
/**
* Run a callback once through {@link timeout}.
* @param callback - Work to run after the delay.
* @param delay - Delay in milliseconds.
* @returns Disposer that cancels the pending callback early.
* @deprecated Use `ctx.timeout()` instead.
*/
setTimeout(callback: () => void, delay: number): () => void {
return this.timeout(callback, delay)
}
/**
* Run a callback repeatedly through {@link interval}.
* @param callback - Work to run on each tick.
* @param delay - Interval in milliseconds.
* @returns Disposer that stops the interval early.
* @deprecated Use `ctx.interval()` instead.
*/
setInterval(callback: () => void, delay: number): () => void {
return this.interval(callback, delay)
}
/**
* Run a callback once after a delay.
* @param callback - work to run.
* @param delay - delay in milliseconds.
* @returns disposer that cancels the callback.
*/
timeout(callback: () => void, delay: number): () => void
/**
* Wait for a delay.
* @param delay - delay in milliseconds.
* @returns promise resolved after the delay.
*/
timeout(delay: number): Promise<void>
timeout(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
const delay = args[0] as number
if (callback !== undefined) {
const dispose = this.ctx.effect(() => {
const timer = globalThis.setTimeout(() => {
void dispose()
callback()
}, delay)
return () => { globalThis.clearTimeout(timer) }
}, 'ctx.timeout()')
return dispose
}
const { promise, resolve, reject } = Promise.withResolvers<void>()
const dispose = this.ctx.effect(() => {
const timer = globalThis.setTimeout(resolve, delay)
return () => {
globalThis.clearTimeout(timer)
reject(new Error('Context has been disposed'))
}
}, 'ctx.timeout()')
return promise.finally(() => { void dispose() })
}
/**
* Run a callback repeatedly.
* @param callback - work to run on each tick.
* @param delay - interval in milliseconds.
* @returns disposer that stops the interval.
*/
interval(callback: () => void, delay: number): () => void
/**
* Iterate over timer ticks.
* @param delay - interval in milliseconds.
* @returns async iterator of ticks.
*/
interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
interval(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
const delay = args[0] as number
if (callback !== undefined) {
return this.ctx.effect(() => {
const timer = globalThis.setInterval(callback, delay)
return () => { globalThis.clearInterval(timer) }
}, 'ctx.interval()')
}
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
const dispose = this.ctx.effect(() => {
const timer = globalThis.setInterval(() => {
nextTask?.resolve({ done: false, value: undefined })
}, delay)
return () => {
globalThis.clearInterval(timer)
if (done !== undefined) return
done = { kind: 'throw', reason: new Error('Context has been disposed') }
nextTask?.reject(done.reason)
}
}, 'ctx.interval()')
return {
next: () => {
if (done === undefined) return (nextTask = Promise.withResolvers()).promise
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
return Promise.reject(done.reason)
},
return: (value: any) => {
if (done === undefined) done = { kind: 'return', value }
nextTask?.resolve({ done: true, value })
void dispose()
return Promise.resolve({ done: true, value })
},
throw: (reason: any) => {
if (done === undefined) done = { kind: 'throw', reason }
nextTask?.reject(reason)
void dispose()
return Promise.resolve({ done: true, value: undefined })
},
[Symbol.asyncIterator]() {
return this
},
} satisfies AsyncIterableIterator<void>
}
/** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any {
let timer: number | undefined
const dispose = this.ctx.effect(() => () => {
disposed = true
globalThis.clearTimeout(timer)
}, label)
const wrapper: any = (...args: any[]): void => {
globalThis.clearTimeout(timer)
timer = trigger(args, disposed)
}
wrapper.dispose = dispose
return wrapper
}
/**
* Return a throttled function whose timer is disposed with the calling Fiber.
* @param callback - Function to throttle.
* @param delay - Minimum interval between calls in milliseconds.
* @param noTrailing - Whether to suppress a delayed trailing call.
* @returns Throttled function with an early disposer.
*/
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
let lastCall = -Infinity
const execute = (...args: Parameters<F>): void => {
lastCall = Date.now()
callback(...args)
}
return this.schedule('ctx.throttle()', (args, disposed) => {
const remaining = delay - Date.now() + lastCall
if (remaining <= 0) {
execute(...args as Parameters<F>)
} else if (!disposed) {
return globalThis.setTimeout(execute, remaining, ...args)
}
}, noTrailing)
}
/**
* Return a debounced function whose timer is disposed with the calling Fiber.
* @param callback - Function to debounce.
* @param delay - Quiet period in milliseconds.
* @returns Debounced function with an early disposer.
*/
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this.schedule('ctx.debounce()', (args, disposed) => {
if (disposed) return
return globalThis.setTimeout(callback, delay, ...args)
})
}
}
/**
* Install the browser timer Service on one Client composition.
* @param ctx - Client context that owns the Service and mixed-in helpers.
* @returns Nothing after registering the Service.
*/
export function provideClientTimer(ctx: Context): void {
new ClientTimerService(ctx)
}
@@ -9,6 +9,7 @@
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
import { Context } from '@deepseek-ai/cordis'
import { TimerService } from '@deepseek-ai/cordis-plugin-timer'
import { describe, expect, it, vi } from 'vitest'
import type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
@@ -68,8 +69,7 @@ interface Bench {
}
/** Mount the browser half over a module table and a loader standing on real fibers. */
async function boot(): Promise<Bench> {
const ctx = new Context()
async function boot(ctx = new Context()): Promise<Bench> {
await ctx.plugin(SlotRegistry)
const factories = new Map<string, () => unknown>()
const fibers = new Map<string, { fiber: unknown }>()
@@ -194,6 +194,49 @@ async function boot(): Promise<Bench> {
}
describe('browser half', () => {
it('installs the shared Timer and disposes scheduled work with its consumer', async () => {
const moduleLoader = Object.getOwnPropertyDescriptor(globalThis, '__ModuleLoader__')
const ctx = new Context()
vi.useFakeTimers()
try {
const bench = await boot(ctx)
expect(bench.ctx.timer).toBeInstanceOf(TimerService)
const timeout = vi.fn()
const interval = vi.fn()
const delayed = vi.fn()
const consumer = bench.ctx.plugin({
inject: ['timer'],
apply(ctx: Context) {
ctx.timeout(timeout, 20)
ctx.interval(interval, 20)
ctx.debounce(delayed, 40)()
},
})
await consumer
await vi.advanceTimersByTimeAsync(20)
expect(timeout).toHaveBeenCalledOnce()
expect(interval).toHaveBeenCalledOnce()
expect(delayed).not.toHaveBeenCalled()
await consumer.dispose()
await vi.advanceTimersByTimeAsync(40)
expect(interval).toHaveBeenCalledOnce()
expect(delayed).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
expect(bench.ctx.timer).toBeInstanceOf(TimerService)
await bench.dispose()
expect(bench.ctx.get('timer')).toBeUndefined()
expect(bench.ctx.timeout).toBeUndefined()
} finally {
try {
await ctx.fiber.dispose()
} finally {
vi.useRealTimers()
if (moduleLoader) Object.defineProperty(globalThis, '__ModuleLoader__', moduleLoader)
else Reflect.deleteProperty(globalThis, '__ModuleLoader__')
}
}
})
it('provides the load engine as the page run-state face', async () => {
const bench = await boot()
expect(bench.ctx.dynamicCordisRunner.getSnapshot()).toEqual([])
@@ -14,6 +14,9 @@
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/timer"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
@@ -3440,12 +3440,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [],
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
description: 'Return a throttled function whose timer is disposed with the current fiber.',
parameters: [],
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
description: 'Return a debounced function whose timer is disposed with the current fiber.',
parameters: [],
},
+3
View File
@@ -10019,6 +10019,9 @@ importers:
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:~
version: link:../../../vendor/loader
'@deepseek-ai/cordis-plugin-timer':
specifier: workspace:~
version: link:../../../vendor/timer
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:*
version: link:../../api/remotes
+11
View File
@@ -234,6 +234,17 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
})
it('inlines the browser-safe Timer entry while sharing Cordis through the module table', () => {
const timer = '@deepseek-ai/cordis-plugin-timer'
expect(resolveId(timer)).toBeNull()
expect(() => resolveId(`${timer}/src/index.ts`)).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/cordis-plugin-hmr')).toThrow(/purity/)
const config = clientConfigs(packageDir('extensions/cordis-client-runner'))[0]
const deps = config?.deps as { neverBundle: (specifier: string) => boolean }
expect(deps.neverBundle(timer)).toBe(false)
expect(deps.neverBundle('@deepseek-ai/cordis')).toBe(true)
})
it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
+101
View File
@@ -0,0 +1,101 @@
/** Timer iterator ordering and cancellation on the vendored Cordis lifecycle. */
import { Context } from '@deepseek-ai/cordis'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
describe('Cordis Timer', () => {
let ctx: Context
beforeEach(async () => {
vi.useFakeTimers()
ctx = new Context()
await ctx.plugin(Timer)
})
afterEach(async () => {
try {
await ctx.fiber.dispose()
} finally {
vi.useRealTimers()
}
})
it('completes concurrent interval reads in order, one per tick', async () => {
const iterator = ctx.interval(10)
const completed: number[] = []
const reads = [0, 1, 2].map(index => iterator.next().then(
(value) => { completed.push(index); return value },
() => undefined,
))
for (let index = 0; index < reads.length; index++) {
await vi.advanceTimersByTimeAsync(10)
expect(completed).toEqual(Array.from({ length: index + 1 }, (_, i) => i))
}
expect(await Promise.all(reads)).toEqual(Array(3).fill({ done: false, value: undefined }))
await iterator.return!()
expect(vi.getTimerCount()).toBe(0)
})
it.each(['return', 'throw', 'dispose'] as const)('settles all pending interval reads on %s', async (operation) => {
const iterator = ctx.interval<string>(10)
const completed: PromiseSettledResult<IteratorResult<void, string>>[] = []
for (let index = 0; index < 3; index++) {
void iterator.next().then(
value => completed.push({ status: 'fulfilled', value }),
(reason: unknown) => completed.push({ status: 'rejected', reason }),
)
}
const error = new Error('stopped')
if (operation === 'return') await iterator.return!('finished')
else if (operation === 'throw') await iterator.throw!(error)
else await ctx.fiber.dispose()
await vi.advanceTimersByTimeAsync(0)
expect(completed).toHaveLength(3)
if (operation === 'return') {
expect(completed).toEqual(Array(3).fill({ status: 'fulfilled', value: { done: true, value: 'finished' } }))
await expect(iterator.next()).resolves.toEqual({ done: true, value: 'finished' })
} else {
const reason = operation === 'throw' ? error : new Error('Context has been disposed')
expect(completed).toEqual(Array(3).fill({ status: 'rejected', reason }))
await expect(iterator.next()).rejects.toThrow(reason.message)
}
expect(vi.getTimerCount()).toBe(0)
})
it.each([false, true])('stops throttle calls and trailing work after disposal (noTrailing=%s)', async (noTrailing) => {
const callback = vi.fn((value: string) => value.length)
const throttled = ctx.throttle(callback, 10, noTrailing)
expectTypeOf(throttled).parameters.toEqualTypeOf<[string]>()
expectTypeOf(throttled).returns.toBeVoid()
throttled('first')
throttled('second')
await vi.advanceTimersByTimeAsync(10)
expect(callback.mock.calls).toEqual(noTrailing ? [['first']] : [['first'], ['second']])
throttled('before disposal')
const calls = callback.mock.calls.length
throttled.dispose()
await vi.advanceTimersByTimeAsync(20)
throttled('after disposal')
expect(callback).toHaveBeenCalledTimes(calls)
expect(vi.getTimerCount()).toBe(0)
})
it('cancels debounce work and makes both wrappers inert after owner disposal', async () => {
const callback = vi.fn((value: string) => value.length)
const debounced = ctx.debounce(callback, 10)
const throttled = ctx.throttle(callback, 10)
expectTypeOf(debounced).parameters.toEqualTypeOf<[string]>()
expectTypeOf(debounced).returns.toBeVoid()
debounced('first')
debounced('last')
await vi.advanceTimersByTimeAsync(10)
expect(callback).toHaveBeenCalledExactlyOnceWith('last')
debounced('pending')
await ctx.fiber.dispose()
debounced('disposed')
throttled('disposed')
await vi.advanceTimersByTimeAsync(10)
expect(callback).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
})
+2 -3
View File
@@ -203,7 +203,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the API',
sessionLogDownload: 'client-side browser download controller — packages/session-query/session-log-export/README.md owns the API',
inputTriggers: 'client-side interface-typed browser service — packages/client/ui-input-trigger/README.md owns the API',
timer: 'client-side dynamic-package timer service — packages/extensions/cordis-client-runner/README.md owns the API',
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
@@ -1025,11 +1024,11 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
jsDoc: '/** Return an async iterator of timer ticks. */',
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
jsDoc: '/** Return a throttled function whose timer is disposed with the current fiber. */',
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
jsDoc: '/** Return a debounced function whose timer is disposed with the current fiber. */',
},
],
+1 -1
View File
@@ -261,7 +261,7 @@ const EXACT_EDITS: readonly ExactEdit[] = [
* identity to share — the framework itself is a requested module-table row
* (external), while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
const VENDORED_LIBRARY = /^@deepseek-ai\\/(?:(?:cosmokit|schemastery)(?:\\/|$)|cordis-plugin-timer$)/
/** Generated descriptor/codec contribution with no shared runtime identity. */`,
expect: 1,
+2 -2
View File
@@ -20,7 +20,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac
| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` |
| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.4 | https://github.com/cordiverse/cordis (`packages/timer`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` |
| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
@@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces.
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
7. **Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`.
9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`.
10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`).
+1 -1
View File
@@ -4,7 +4,7 @@ Status: proposed
## Problem
The [vendor manifest](README.md) records Cordis core 4.0.0-rc.7, Loader 1.0.0-rc.5, Include 1.0.4, HMR 1.0.15 and Timer 1.1.2, with 24 local-modification entries. [Cordis v4.0.0-rc.11](https://github.com/cordiverse/cordis/releases/tag/v4.0.0-rc.11) incorporates some fixes and replaces other mechanisms. Updating every package and retiring every patch in one change would combine configuration, persistence, lifecycle and module-replacement decisions.
The upgrade starts from the [vendored](README.md) baselines Cordis core 4.0.0-rc.7, Loader 1.0.0-rc.5, Include 1.0.4, HMR 1.0.15 and Timer 1.1.2, with 24 local-modification entries. [Cordis v4.0.0-rc.11](https://github.com/cordiverse/cordis/releases/tag/v4.0.0-rc.11) incorporates some fixes and replaces other mechanisms. Updating every package and retiring every patch in one change would combine configuration, persistence, lifecycle and module-replacement decisions.
DSH also has consumers that upstream Cordis does not own. Volatile configuration fields are stable references updated without restarting plugins; the settings UI reads their live values. A profile selects ordered plugin bundles and configuration overrides. [DSH HMR](../packages/boot/hmr/README.md) coordinates module replacement and profile changes through `ctx.hmr`. An entry's module namespace is its raw import result, retained before selecting `default` or `apply` so HMR can identify the module already loaded. These consumers must remain usable at every merge point.
+6 -4
View File
@@ -18,8 +18,7 @@ const dispose = root.timeout(() => {
dispose()
```
Timer handles are registered on the current fiber, so they are cleared
automatically when the plugin that created them is disposed.
Timer handles are registered on the current fiber, so they are cleared automatically when the plugin that created them is disposed.
## API
@@ -32,5 +31,8 @@ automatically when the plugin that created them is disposed.
| `ctx.throttle(callback, delay, noTrailing?)` | Return a throttled function with `.dispose()`. |
| `ctx.debounce(callback, delay)` | Return a debounced function with `.dispose()`. |
`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for
`ctx.timeout()` and `ctx.interval()`.
`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for `ctx.timeout()` and `ctx.interval()`.
Concurrent `next()` calls on an interval iterator complete in request order, one per tick. Returning from or throwing into the iterator settles every pending read; disposing the owning fiber rejects every pending read. Ticks without a pending read are discarded.
Throttle and debounce wrappers return `void`, regardless of the callback return type. Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later calls inert. `noTrailing` suppresses delayed throttle calls while retaining leading calls until disposal.
+22 -16
View File
@@ -6,7 +6,7 @@ declare module '@deepseek-ai/cordis' {
}
}
type WithDispose<T> = T & { dispose: () => void }
type Scheduled<F extends (...args: any[]) => any> = ((...args: Parameters<F>) => void) & { dispose: () => void }
/** Disposable timer helpers mixed into Cordis contexts. */
export class TimerService extends Service {
@@ -66,33 +66,37 @@ export class TimerService extends Service {
}, 'ctx.interval()')
} else {
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
const nextTasks: PromiseWithResolvers<IteratorResult<void>>[] = []
const dispose = this.ctx.effect(() => {
const timer = setInterval(() => {
nextTask?.resolve({ done: false, value: undefined })
nextTasks.shift()?.resolve({ done: false, value: undefined })
}, delay)
return () => {
clearInterval(timer)
if (done) return
done = { kind: 'throw', reason: new Error('Context has been disposed') }
nextTask?.reject(done.reason)
for (const task of nextTasks.splice(0)) task.reject(done.reason)
}
}, 'ctx.interval()')
return {
next: () => {
if (!done) return (nextTask = Promise.withResolvers()).promise
if (!done) {
const task = Promise.withResolvers<IteratorResult<void>>()
nextTasks.push(task)
return task.promise
}
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
return Promise.reject(done.reason)
},
return: (value) => {
if (!done) done = { kind: 'return', value }
nextTask?.resolve({ done: true, value })
for (const task of nextTasks.splice(0)) task.resolve({ done: true, value })
dispose()
return Promise.resolve({ done: true, value })
},
throw: (reason) => {
if (!done) done = { kind: 'throw', reason }
nextTask?.reject(reason)
for (const task of nextTasks.splice(0)) task.reject(reason)
dispose()
return Promise.resolve({ done: true, value: undefined })
},
@@ -103,42 +107,44 @@ export class TimerService extends Service {
}
}
private _schedule(label: string, trigger: (args: any[], isDisposed: boolean) => any, isDisposed = false) {
private _schedule(label: string, trigger: (args: any[]) => number | NodeJS.Timeout | undefined) {
let timer: number | NodeJS.Timeout | undefined
let isDisposed = false
const dispose = this.ctx.effect(() => () => {
isDisposed = true
clearTimeout(timer)
timer = undefined
}, label)
const wrapper: any = (...args: any[]) => {
if (isDisposed) return
clearTimeout(timer)
timer = trigger(args, isDisposed)
timer = trigger(args)
}
wrapper.dispose = dispose
return wrapper
}
/** Return a throttled function whose timer is disposed with the current fiber. */
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): Scheduled<F> {
let lastCall = -Infinity
const execute = (...args: any[]) => {
lastCall = Date.now()
callback(...args)
}
return this._schedule('ctx.throttle()', (args, isDisposed) => {
return this._schedule('ctx.throttle()', (args) => {
const now = Date.now()
const remaining = delay - now + lastCall
if (remaining <= 0) {
execute(...args)
} else if (!isDisposed) {
} else if (!noTrailing) {
return setTimeout(execute, remaining, ...args)
}
}, noTrailing)
})
}
/** Return a debounced function whose timer is disposed with the current fiber. */
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this._schedule('ctx.debounce()', (args, isDisposed) => {
if (isDisposed) return
debounce<F extends (...args: any[]) => any>(callback: F, delay: number): Scheduled<F> {
return this._schedule('ctx.debounce()', (args) => {
return setTimeout(callback, delay, ...args)
})
}