Compare commits

...
Author SHA1 Message Date
imccyu 1f81c047fc Merge pull request #6999 from deepseek-harness/worktree/release-dsh-0.2.1-rc.3
release: next version [email protected]
2026-10-23 11:29:36 +08:00
imccyu d02ea370ee release(dsh): 0.2.1-rc.3 2026-10-23 01:11:35 +08:00
Yichen Jiang c48f8523f2 feat(llm): follow the official /models listing on the DeepSeek API-key route (#6944)
* refactor(llm-deepseek): share the /models listing machinery

Move the account route's `/models` request and decoding, its
credential-scoped listing cache, and listed-catalog resolution with the
version gate into the shared DeepSeek Messages library, so a second
route can follow the same listing. Names drop the account prefix, the
cache takes each access's endpoint settings with the call, and it gains
`invalidate()` for a forced reload. `isBuiltInCatalog()` recognizes the
catalog an uncustomized `models` setting resolves to.

The account route keeps its behavior; only its imports and the
override diagnostic wording ("model listing") change.

* feat(llm-deepseek-api-key): follow the official /models listing

While `models` equals the built-in catalog and the Messages origin is
https://api.deepseek.com, `deepseek-official` serves the official
`GET /models` listing, authenticated with the API key in `x-api-key`.
Loading, refresh, fallback, merging, and the client-version gate match
the account route: the first access waits up to `modelsTimeoutMs`, a
listing older than `modelsMaxAgeMs` refreshes in the background on the
next access, a failure keeps the last good listing or the built-in
catalog, and listed models this build cannot serve are refused before
network I/O.

A customized `models` list replaces the catalog and requests nothing.
Switching between a customized catalog and the listing discards the
cached listing, so Restore defaults reloads it on the next access.

Suites that boot the route on the official endpoint now answer the
listing themselves instead of reaching the network.

* docs: describe the official route model listing

Document when `deepseek-official` follows the official `/models`
listing, its timing fields, customization precedence, and the forced
reload after Restore defaults in the API-key README, the shared
transport README, and the provider guide. Record the decision in a new
Agent Note, keep the account note's statement of where the shared
listing code lives current, and regenerate the configuration catalog.

* test(llm-deepseek-api-key): pin the once-per-listing hidden-entry diagnostic

* docs: regenerate module and event graphs for the shared listing

* fix(llm-deepseek-api-key): report credential failures on the model request

On the official endpoint, `connect` waited for the listing view, whose
credential lookup rethrew a malformed key or a credential-store failure
before the agent loop recorded the prompt. A failed lookup now resolves
the connection from the current listing view, so `resolveAuth` reports
the failure on the model request and the turn keeps the prompt and the
failed attempt. A caller abort still rejects.

* docs(llm-deepseek-api-key): name which hidden listing entries are logged

* test: keep unit suites off the official model listing

Suites that boot the DeepSeek routes with a dummy or inherited key now
request `GET https://api.deepseek.com/models` on the official endpoint.
A unit-lane setup file rejects that request at once, so such a suite
serves the built-in catalog instead of reaching production or waiting
out the listing deadline on a network that never answers; suites that
exercise the listing answer `fetch` themselves. Other requests pass
through unchanged.

* feat(llm-deepseek): read tool_update from the model listing

Both DeepSeek routes now take a listed model's Messages tool update mode
from `api_capabilities.anthropic_messages.tool_update`, alongside the
system prompt mode. `in-history` and `addition-only` replace the
bundled mode; any other value reads as `leading-only` and removes it.
An absent or `null` mode keeps the bundled one, so a listing that does
not declare the field leaves `deepseek-flash` on `addition-only`.

`system_prompt_update: null` likewise keeps the bundled mode now; it
previously read as `leading-only`, which a serializer writing empty
values as null would have turned into a cache-unfriendly downgrade.

* fix(llm-deepseek): sync the built-in context window with the official listing

The official `/models` listing declares a 1,048,576-token context window
for `deepseek-flash` and `deepseek-v4-pro`; the built-in catalog said
1,000,000, a decimal-versus-binary mismatch. Both built-in entries now
carry the listed value, so offline starts and listing failures size the
context the same way the listing does. The Models page shows the exact
count for these rows, since 1,048,576 has no decimal K/M spelling.

`defaultContextWindow`, the fallback for model ids the catalog does not
describe, stays 1,000,000.

* test(llm-deepseek-api-key): reject the stubbed listing request with an Error

* test(cli): keep the built SDK smoke off the official model listing

The SDK profile's initialize now resolves the deepseek-official catalog,
which requests GET https://api.deepseek.com/models on the official
endpoint. With the smoke's fixture key that request answers 401 and the
route warns on stderr, so the test's empty-stderr assertion failed in the
e2e and snapshots lanes. Point the run at a loopback mock and assert it
received no model call.

* test(llm-deepseek): compose the malformed-key turn without the native executor

release/dsh-0.2.1 predates @deepseek-ai/dsh-tool-executor-native; its tool
runtime executes tools itself. Compose the official-route turn the way
account-routing.spec.ts does on this branch.

* fix(llm-deepseek-api-key): gate nothing when the credential lookup fails

The listing cache switches scope only after it reads a credential, so a
failed lookup left `connect` serving the previous credential's listing.
After a key whose listing refuses a model was replaced by a malformed one,
`admit` refused that model with CLIENT_UPGRADE_REQUIRED before the turn
recorded the prompt, instead of `resolveAuth` reporting the credential
error on the request. Serve the built-in catalog without listing gates
when the lookup fails.

* test(llm-deepseek-api-key): pin the once-per-listing diagnostic across request preparation

The hidden-entry diagnostic test repeated the same discovery assertion to
prove a second pass reports nothing new, which read as a duplicate. Assert
the single warning after the request preparations instead; they resolve
the same listing, so dropping the per-listing deduplication still fails
the test.

* docs(llm-deepseek-api-key): note that a list matching an earlier built-in catalog counts as customized

Customization is judged against the running version's built-in catalog,
so a saved `models` list equal to an earlier version's catalog stops
following the listing once the built-in catalog changes, until Restore
defaults. Record the consequence in the Agent Note and the README's known
limitations.
2026-10-22 20:42:19 +08:00
imccyu 39d2903841 fix(ui): separate navigation pins from selection and show menu icons (#6883)
* fix(ui): separate navigation pins from selection and show menu icons

* fix(ui): simplify More menus and fold conversation views on resize

* test(ui): fix navigation CI regressions and refresh catalogs

* refactor(ui): render sidebar panel icons directly

* fix(ui): keep Chat visible and stop persisting navigation pins

* refactor(ui): drop unreachable pin validation from in-memory stores
2026-10-22 20:05:33 +08:00
675164957a feat(analytics): track navigation entries and preset selections (#6600)
* feat(analytics): track navigation entry clicks, preset switches, and new-session sends

Rebuilds the click events of #6600 on the region-owned More menus in
release/dsh-0.2.1. Exposure events, creator_mode_enter, and the
ui-workspace onOpened observer are not included.

- sidebar_menu_click gains position plus the new_session and more entries:
  New Session (sidebar button, brand, titlebar), Plugins and Automation
  rows, the left sidebar More button, and entries opened from its menu.
- more_menu_item_click records Browse other plugins and Create your plugin
  per region.
- agent_preset_switch reports applied or unbound staged picks; the seat
  controller now returns an explicit apply outcome.
- code_work_view_toggle reports a successful change of the setting.
- send_button_click adds is_new_session and the Session's preset category.

Co-authored-by: imccyu <[email protected]>

* test(analytics): cover navigation, preset switch, toggle, and send analytics

Co-authored-by: imccyu <[email protected]>

* docs(analytics): document click events and regenerate catalogs

Co-authored-by: imccyu <[email protected]>

* test(ui): reuse the developer tools row accessor without a new unknown cast

Co-authored-by: imccyu <[email protected]>

* fix(analytics): classify preset switches by preset id only

agent_preset_switch now maps the preset id the same way send_button_click does: cordis is creator, ptc, minimal and standard keep their ids, and any other id is other; a published preset name no longer changes the category.

Co-authored-by: imccyu <[email protected]>

---------

Co-authored-by: dingyifan <[email protected]>
Co-authored-by: imccyu <[email protected]>
2026-10-21 11:17:55 +08:00
5ff29190ed feat(ui): add navigation entry customization (#6247)
* fix(ui): avoid tooltip trigger overlap when a side fits

* feat(ui): add navigation entry customization

* fix(test): keep tooltip geometry independent of JSX types

* fix(ui): align customization catalogs and integration fixtures

* test(ui): complete customization lifecycle and release integration coverage

* fix(ui): return entry discovery to the installed plugin list

* docs: refresh module graph for entry customization

* build: refresh workspace links for the moved entry customization package

* docs: regenerate catalogs and pairing hashes for the moved entry customization package

* test(web): cover default-loaded entry customization in the Web e2e

* test(ui): describe entry customization as a browser plugin in the host entry spec

* build: order entry customization publication files as workspace constraints require

* test(release): drop the moved entry customization package from the experimental family list

* style(ui): use the shared focus ring color on entry customization triggers

* test(web): re-record goldens for the default-loaded More navigation menus

* test(web): add the More conversation views trigger to the remaining CI-reported goldens

* docs: record region-owned pins for plugin entry customization

* refactor(ui): rebuild entry customization on region-owned pins and More menus

- Each navigation region (left sidebar, Conversation header, right sidebar
  guide) persists its own unpinned list under
  dsh.slot.manual-order.<list slot key>.v1, filters its own launchers, and
  draws its own More button and Menu around its actions slot.
- ui-entry-customization only registers the shared menu rows (entries with
  pin buttons, Browse other plugins, Create your plugin) into the three
  actions slots and keeps no state.
- Remove ctx.layout.entryVisibility, the plugin store, left-sidebar drag
  reordering, and agentPresetNavigation; ui-layout and ui-agent-preset match
  release/dsh-0.2.1 again.
- Keep the release <nav> and Conversation tab-bar visibility conditions.
- Move the More glyph into ui-primitives as IconAppsAddOutline{Regular,Medium}.

* docs: compile the entry customization owner props example

* docs(ui): describe the wrapping Conversation tab row in the header comment

* fix(ui): return New Session to the Workspace blank and its unsent draft

- New Session (startSession without options) takes the blank-reuse path
  again instead of creating a fresh Session on every call, which hid the
  previous blank and its unsent draft since a5c9c2aaeef (#5775).
- A request that names an Agent preset skips blanks listing a different
  preset, so Creator entries no longer take over an ordinary draft.
- Record the change in a v0.2.1-rc.2 upgrade guide; the Host refresh of a
  stale loaded blank stays deferred (see the Agent Note).

* docs: regenerate the Cordis inspect catalog for New Session blank reuse

* fix(ui): keep the selected entry visible while unpinned

- Left-sidebar rows and View tabs keep the selected entry, with aria-current or aria-selected, until another entry is selected.
- Entry menu Creator action takes no region argument; the registration's region selects the prompt.
- Document the 2px panel-row and 32px View-tab gaps; drop commit hashes from the New Session Agent Note.

* fix(ui): leave a blank of another preset unstaged when starting Creator

startCreatorDraft stages cordis only when the main blank lists no preset or already lists cordis; navigation opens a separate Creator blank otherwise, so a Standard draft blank keeps its preset.

* feat(ui): choose whether a draft prompt replaces, appends to, or keeps an existing draft

- DraftInitializationOptions gains whenDraftExists ('replace' | 'append' | 'keep', default 'keep'); clearPreviousDraft stays and maps to 'replace' only when whenDraftExists is omitted.
- Append adds the prompt as new paragraphs after the existing text and references; attachments stay in every mode.
- The three Create your plugin entries append their regional prompt.

* test(web): align e2e with New Session blank reuse and the visible selected entry

* refactor(ui): share the draft paragraph writer between setDraft and appendDraft

---------

Co-authored-by: Yif <[email protected]>
Co-authored-by: imccyu <[email protected]>
2026-10-20 15:17:13 +08:00
imccyu 687f282e86 Merge pull request #6408 from deepseek-harness/worktree/release-dsh-0.2.1-rc.2
release: next version [email protected]
2026-10-17 15:22:43 +08:00
imccyu d1b9c04067 release(dsh): 0.2.1-rc.2 2026-10-17 15:10:31 +08:00
Yudong HanandLegGasai fda98dadd2 feat(web): add DeepSeek Search and Fetch APIs behind a settings switch (#6103)
* fix(web-search-deepseek): disable thinking for auxiliary search requests

The provider discards the generated text of the auxiliary Messages turn,
so reasoning tokens have no consumer. DeepSeek enables thinking by default,
which adds latency and output tokens to every search.

Fixes #6099.

* feat(web): add opt-in traditional Search and Fetch APIs

* test(web): verify API settings persistence and refresh Session evidence

* fix(web): require an explicit Search API base with its route prefix

* docs(web): document direct Search and Fetch API decisions

* fix(web): keep Messages search default behind the new Web Search API switch

- Rename useTraditionalApi/traditionalBaseURL to useSearchApi/searchApiBaseURL and
  label the switch "Use the new Web Search API"; Messages search stays the default.
- Default the Search / Fetch API base to https://api.deepseek.com/v1/tools.
- Authenticate the Search API with an API key only: DeepSeek Account tokens
  returned an empty HTTP 404 on /v1/tools/* when probed on 2026-10-16.
- Remove the count setting, which tool-web's maxResults always overrides, and
  the undocumented DeepSeek-API-Version header.
- Name the active API's base URL field and the current settings path in
  endpoint guidance; re-record the endpoint guidance Session.
- Keep WebFetchResult.statusCode required: DeepSeek Fetch reports HTTP 200 and
  drops the page title, so the fetch-metadata widening and its upgrade guide
  are removed.

* fix(web): accept account sign-in on the live Search API

The public Search and Fetch routes went live on 2026-10-16 and accept the
DeepSeek Account token, so account-route Sessions send it to the Search API
as they do for Messages search, and the settings copy no longer says account
sign-in does not apply.

Record the live behavior in the README and Agent Note: summaries of
1,000-2,300 characters, an HTTP 500 for an unresolvable Fetch target, and
migration notes for gateways without /v1/tools routes. Test doubles now use
the live response fields and RFC 3339 offsets.

* feat(web): configure Search API result count and send the triggering question

- Add web-search-deepseek.count (1-50, default 10, the service default) and
  show it as "Results per search" in Search API mode, where "Max searches per
  request" applies only to Messages; a smaller request maxResults still wins.
- Send the initiating Session's latest human prompt as ori_question, with
  whitespace collapsed and cut to the API's 128-character limit; blank or
  absent prompts omit it, and sendOriginalQuestion: false disables it. The
  settings hint states that the question is sent.
- Match the web search double on the request's query field, since
  ori_question now carries every query in the prompt.

* fix(client): unify web search endpoint override controls

* feat(web): configure registered search and fetch providers through shared settings

* feat(web): include Exa and Perplexity in provider settings

* test(web): cover provider settings branches and refresh plugin snapshots

* revert(web): drop the provider dropdown, schema-driven form, and Exa/Perplexity settings

Revert aed8c5cb609, d88c47dcd80, and 036a5a337d3. The Web search settings
page returns to the DeepSeek toggle; the DeepSeek Search and Fetch API split
is reintroduced as one plugin in the following commit.

* feat(web): move the DeepSeek Search and Fetch APIs into web-deepseek-api

The new web-deepseek-api plugin registers deepseek-search-api and
deepseek-fetch-api over one credential and API base, and exports the
authentication and redirect-rejecting transport that web-search-deepseek now
reuses for Messages search only. The web service reads searchProvider and
fetchProvider for each operation, so the Web search settings switch selects
both APIs together on save and restores Messages search with local HTTP fetch
when turned off. The switch hint links to the DeepSeek search and fetch guide.

* refactor(client): keep the web search endpoint fields on the shared override controls

Drop the always-visible reset and the inherited-address placeholder from the
Web search page, restoring ui-primitives to its base. Each endpoint field shows
the selected section's effective value and offers Reset to default only for an
override, like every other settings field.

* refactor(web): keep Messages search independent of web-deepseek-api

Restore dsh-web-search-deepseek to its own credential and transport code,
keeping only the disabled-thinking request and the current settings guidance,
so the Messages plugin no longer depends on the Search and Fetch API package.
dsh-web-deepseek-api keeps its transport internal; the regions that repeat the
Messages helpers carry jscpd:ignore markers, with the rationale recorded in the
Search and Fetch API Agent Note.

* refactor(web): keep the web-deepseek-api request type module-private

---------

Co-authored-by: LegGasai <[email protected]>
2026-10-17 15:08:08 +08:00
Yichen Jiang ca15fe4f17 Merge pull request #6417 from deepseek-harness/worktree/deepseek-flash-1017-badge
feat(web): replace the DeepSeek Flash suffix with a temporary badge
2026-10-17 13:54:15 +08:00
Yichen Jiang 3c937cd0ba feat(web): display a temporary 1017 badge for DeepSeek Flash 2026-10-17 13:33:35 +08:00
Yichen Jiang 32efe36a4e revert: restore the bundled DeepSeek Flash display name 2026-10-17 13:23:06 +08:00
Yichen Jiang c6acc07232 Merge pull request #6406 from deepseek-harness/worktree/deepseek-flash-1017
fix(llm): append 1017 to the bundled DeepSeek Flash display name
2026-10-17 09:23:32 +08:00
Yichen Jiang 9404cd1790 Merge branch 'master' into worktree/deepseek-flash-1017 2026-10-17 09:13:01 +08:00
Yichen Jiang 7d844abc87 fix(llm): append 1017 to the bundled DeepSeek Flash display name 2026-10-17 09:06:36 +08:00
imccyu 1600d8a1e1 Merge pull request #6387 from deepseek-harness/worktree/release-dsh-0.2.1-rc.1
release: next version [email protected]
2026-10-16 21:46:57 +08:00
imccyu c6e89e6755 release(dsh): 0.2.1-rc.1 2026-10-16 21:39:18 +08:00
imccyu ca912356fb Merge pull request #6388 from deepseek-harness/worktree/release-vendor-4.0.5
release(vendor): prepare cordis 4.0.5 stable packages
2026-10-16 21:19:31 +08:00
imccyu d9c04d6cc6 release(vendor): cordis 4.0.5, cosmokit 1.8.6, group 1.0.5, hmr 1.0.20, include 1.0.10, loader 1.0.6, logger-console 1.0.5, schemastery 3.18.5, timer 1.1.7 2026-10-16 20:53:44 +08:00
imccyu 1517a4dbca Merge pull request #6327 from deepseek-harness/worktree/release-dsh-0.2.1-alpha.5
release: next version [email protected]
2026-10-16 19:02:34 +08:00
imccyu a5e553a5ee Merge branch 'master' into worktree/release-dsh-0.2.1-alpha.5 2026-10-16 18:44:50 +08:00
lsdsjy 5ee0f50811 Merge pull request #6384 from deepseek-harness/feat/account-notice-stack
feat(account): 支持通用通知字段和多通知层叠展示
2026-10-16 18:40:03 +08:00
lsdsjy 00a1155f5a test(account): 修复通知动画测试的类型感知 lint 2026-10-16 18:26:26 +08:00
lsdsjy da6c16747e fix(account): 平滑过渡不同高度的通知卡片 2026-10-16 18:21:04 +08:00
Yichen Jiang 29a497ee5c Merge pull request #6357 from deepseek-harness/worktree/model-label-slot
feat(web): add provider-aware model label slot
2026-10-16 18:12:40 +08:00
lsdsjy 474d7b7989 docs(account): 同步通知元数据类型依赖图 2026-10-16 18:04:16 +08:00
lsdsjy 1f312b6282 fix(account): 保留未校验的通知元数据 2026-10-16 17:58:20 +08:00
lsdsjy 6ac5c103db fix(account): 修复通知空标题和过期队首处理 2026-10-16 17:52:44 +08:00
lsdsjy 2dcd031a8e feat(account): 支持通用通知字段和多通知层叠展示 2026-10-16 17:14:42 +08:00
Yichen Jiang f87c08950a fix(web): memoize model label providers 2026-10-16 16:34:19 +08:00
imccyu 0a49d91bb2 release: setup next release version for [email protected] 2026-10-16 16:17:09 +08:00
Yichen Jiang 1b385d6e2a fix(web): keep model menu positioning local 2026-10-16 16:01:04 +08:00
imccyu e28419f15b Merge pull request #6353 from deepseek-harness/fix/sidebar-pageup-shift
fix(web): clip the collapsed right sidebar so PageUp cannot shift the frame
2026-10-16 15:49:54 +08:00
fz d614ae2769 Merge pull request #6348 from deepseek-harness/fix/ptc-prompt-alignment
fix(prompts): restore compatible PTC guidance and SDK persona
2026-10-16 15:48:04 +08:00
fz 839bd5131d refactor(tools): compose PTC instructions without prose replacement
Pass execution-specific phrases directly to the run_code flavor factory and render SDK instructions from explicit program-format branches. Keep all emitted text byte-identical while removing coupling to exact shared prose. Validate the owning tests with full scoped coverage, focused session/SDK replays, and a complete build.
2026-10-16 15:30:12 +08:00
fz c34a107428 test(sdk): apply cache E2E retry settings and expose provider failures
Set the required normal retry mode so the Loader accepts the full provider patch, and type-check the policy before JSON serialization. Assert the effective request settings and successful warm-up turns, report failed attempt causes immediately, and inherit suite retries for transient external API failures.
2026-10-16 15:16:35 +08:00
Yichen Jiang c78f3fd33d fix(web): keep model label menus inside the viewport 2026-10-16 15:16:21 +08:00
Yichen Jiang 919aa5f400 feat(web): add provider-aware model label slot 2026-10-16 14:24:47 +08:00
Shigma 1a33461f2b Merge pull request #6182 from deepseek-harness/docs/cordis-rc11-plan
docs(cordis): Cordis rc.11 vendor upgrade plan
2026-10-16 14:23:05 +08:00
imccyu 8953554b5e fix(web): clip the collapsed right sidebar so PageUp cannot shift the frame
While the right sidebar is collapsed, its docked hosts are translated past
the frame's right edge and widen the frame's scrollable overflow. Outside
macOS, PageUp in a text field moves the caret and reveals it against the
start edge of every scrollable ancestor, so the `overflow: hidden` frame
scrolled sideways and the whole UI shifted left.

Clip the panel's overflow. `overflow: clip` does not make the panel a scroll
container and does not clip the fixed-position floating panels, whose
containing block is the viewport; the DockLayout ancestor contract now says so.

Fixes #6341
2026-10-16 14:21:08 +08:00
fz d6adc5d9b9 fix(prompts): restore compatible PTC guidance and SDK persona 2026-10-16 13:14:24 +08:00
imccyu 085f3c6a58 Merge pull request #6339 from deepseek-harness/ci/master-baseline-canary-1016
test(web): keep page timers running under the pinned fixture day
2026-10-16 12:41:53 +08:00
imccyu 4cea5c36d2 test(web): sweep the Host clock distance in the pinned-clock regression
The page clock failure depended on the real-to-fixture distance modulo
2^32 ms, so a regression run on today's date alone passes against the old
fixture for half of every period. The test now shifts the Host clock across
eight phases of that period; the page.clock fixture fails every phase,
either with stopped page timers or with a Date that does not advance.
2026-10-16 12:23:10 +08:00
imccyu 81803f7ca5 test(web): keep page timers running under the pinned fixture day
pinHostClock replaces Date.now in the Vitest worker, where Playwright's
server also runs. page.clock.setFixedTime therefore recorded the fixture
day, and after a navigation the page clock replayed the gap to the real
day. The resulting negative timer delay was truncated to a 32-bit integer
and, from 2026-10-15T13:45:20Z, became a delay of weeks that stopped every
page timer, failing the steering and seeded-history suites.

pinBrowserClock now replaces only the page's Date so it reads the fixture
day while native timers and animation frames keep their real schedule; a
regression test navigates twice with both clocks pinned.
2026-10-16 12:07:47 +08:00
winewill 3f621c65c3 Merge pull request #6281 from deepseek-harness/feat/windows-runner-supervision
feat(desktop): supervise Windows runner and reject debug publications early
2026-10-16 10:47:39 +08:00
Yichen Jiang e412760f94 Merge pull request #6275 from deepseek-harness/fix/skill-menu-plugin-change
fix(ui-skill): 开关插件后刷新 `/` 菜单的 skill 列表
2026-10-16 10:38:32 +08:00
Yichen Jiang 2d7f15a644 Merge pull request #6304 from deepseek-harness/worktree/terminal-session-list
fix(web): share terminal discovery per session
2026-10-15 22:42:21 +08:00
Yichen Jiang 86a402200d Merge pull request #6301 from deepseek-harness/worktree/release-dsh-0.2.1-alpha.4
release: [email protected]
2026-10-15 20:22:44 +08:00
gengruilin ba392ae0e7 docs(ui-skill): record the catalog gaps a plugin change leaves
State why every plugin manager operation drops every cached catalog, and
list two remaining gaps among the known limitations: on-disk SKILL.md
changes forward no event to an open session, and draft skill highlighting
waits for the next catalog fetch after an invalidation.
2026-10-15 20:17:59 +08:00
Tianyi Cui 7f338ce3be Merge pull request #6272 from deepseek-harness/fix/codex-partial-answer-phase
fix(subagent-codex): accept the partial_answer agent message phase
2026-10-15 20:13:11 +08:00
Yichen Jiang 0ae278b935 fix(web): share terminal discovery per session 2026-10-15 20:12:52 +08:00
imccyu f2235d6c02 release(dsh): 0.2.1-alpha.4 2026-10-15 20:00:58 +08:00
winewill b8f8abc5d6 fix(desktop): publish to administratively unversioned COS buckets 2026-10-15 18:19:27 +08:00
winewill af66022ace fix(desktop): default Windows packaging to signed 2026-10-15 17:52:13 +08:00
winewill 5e23825f15 fix(desktop): reject debug publications early and harden runner supervision 2026-10-15 17:40:38 +08:00
winewill cfa52b0bac feat(infra): supervise Windows packaging runner with Task Scheduler 2026-10-15 17:19:42 +08:00
ZiyaZhang e480bda8d2 fix(subagent-codex): call the payload the selected answer, not the final one
Address the ds-review-bot round on #6272: the README summary, completion
notice, token effect, and Known Limitations lines no longer call the
payload the final answer; the empty-result error reads "without an
answer"; the ordering spec also interleaves commentary; the partial_answer
branch cites its upstream source.
2026-10-15 01:44:26 -07:00
Yudong Han e44061c934 Merge pull request #6264 from deepseek-harness/fix/tool-exit-badge
fix(ui-tool): show command exits in neutral inline capsules
2026-10-15 16:41:15 +08:00
gengruilin 6910cd6e9c fix(ui-skill): refresh the slash menu after plugin manager changes
The browser skill source cached each Session's catalog until a preset
switch or connection reset, so an open Session kept listing the skills of
a plugin switched off on the Plugins page and missed those of a plugin
switched on. Clear every cached catalog on the forwarded
plugin-manager/changed event, which the Host emits after the operation's
reload.

Fixes #6268
2026-10-15 16:36:30 +08:00
yudshj 39d3849452 refactor(ui-tool): separate Host exit parsing fix 2026-10-15 16:23:48 +08:00
Yichen Jiang 0a11184ac3 Merge pull request #6261 from deepseek-harness/worktree/model-reasoning-level-config-2a9bbb
feat(models): edit reasoning levels of custom model API models
2026-10-15 16:21:44 +08:00
yudshj 174c08563f test(snapshot): refresh nullable terminal exit descriptor 2026-10-15 16:08:44 +08:00
yudshj e8488f6b41 fix(shell): preserve missing exit codes in terminal presentation 2026-10-15 15:57:11 +08:00
Yichen Jiang a31a992113 Merge pull request #6267 from deepseek-harness/worktree/sad-jemison-fa352d
test(goal): disarm the seeded goal in the Loader e2e
2026-10-15 15:53:26 +08:00
Yichen Jiang 7d654c9d8a fix(models): drop the reasoning switched-off hint 2026-10-15 15:53:08 +08:00
yudshj 95e76236bf refactor(ui-tool): isolate exit badge and cover long descriptions 2026-10-15 15:46:03 +08:00
Yichen Jiang b542292bf9 fix(models): read reasoning levels from the pi-ai schema
The reasoning-level editor hardcoded pi-ai's seven levels, so a level a
pi-ai upgrade adds would be missing from the page and a stored key outside
the list would be dropped by the next edit. The levels now come from the
llm-pi-ai namespace schema, the key union of reasoningEfforts, read the
way protocolChoices reads the protocols, and travel explicitly from the
Models section and the provider editor through the model list to each row.
A stored key the schema does not list keeps its row and stays selectable.
The reasoning control now appears wherever that list is non-empty instead
of being inferred from a missing catalog provider.
2026-10-15 15:31:54 +08:00
yudshj 97a833471f fix(ui-tool): reserve inline space for exit capsules 2026-10-15 15:02:29 +08:00
Yichen Jiang 63e99ee2bf test(goal): disarm the seeded goal in the Loader e2e
The goal domain e2e runs over the shipped headless profile, whose base
bundle mounts goal-round-driver. GoalService.create() arms the goal the
fixture seeds, so the driver queued round 1 when the task turn went idle
and the test's headless driver disposed the context mid-round. The
assertions held only while the aborted round's turn/end missed the log
before disposal; an extra await in the agent loop's pre-step path (#6024)
let it land and the test failed deterministically.

Disarm the goal right after creating it. Activation is process-local, so
the persisted goal/change snapshot is unchanged and the driver starts no
round. Also assert a single turn/start, which records a started round
even when disposal aborts it before its turn/end is persisted.
2026-10-15 14:56:55 +08:00
Yichen Jiang a0a04158f8 fix(models): keep reasoning edits with their model and mirror pi-ai refusals
The reasoning-level editor kept the levels it switched off, its row order,
and typed text in component state, while the model list keys rows by
position, so deleting a row handed that state to the next model: turning
the survivor's switch on wrote the deleted model's levels into it. The
editor now renders only from the model; rows follow the stored order,
switching on always starts from the defaults, and only the focused field
keeps its typed text until the field is left.

The reasoning check moves out of the shared model validator into the two
cards whose rows edit it, so DeepSeek and catalog routes are untouched,
and it now mirrors every pi-ai refusal: a valueless field, a missing value
on a level besides off, and an empty value. A refused value marks its
field, and leaving that field repairs it with the level name.

The Providers guide now points custom model API users to the page before
the YAML, and both package READMEs describe the new behavior.
2026-10-15 14:53:56 +08:00
winewill fa13ef7a66 Merge pull request #6227 from deepseek-harness/fix/windows-installer-file-locks
fix(desktop): retry Windows installer directory locks and report failures
2026-10-15 14:37:39 +08:00
winewill 58c8cef7dc Merge pull request #6262 from deepseek-harness/build/desktop-test-branches
feat(desktop): 支持 test 分支打包并中文化 Actions 表单
2026-10-15 14:22:24 +08:00
yudshj 00f897b048 fix(ui-tool): show command exits in neutral inline capsules 2026-10-15 14:20:22 +08:00
ZiyaZhang 08d57291ad fix(subagent-codex): accept the partial_answer agent message phase
Codex app-server 0.162 adds `partial_answer` to the agentMessage phases
(stable answer text that later assistant output or tool calls may follow).
The wire treated every phase other than final_answer, commentary, and null
as fatal, so one such message failed the whole delegation. Record the
latest partial answer and select final, then partial, then unphased text;
unknown phases still fail the run. The pinned @openai/codex 0.153.4 payload
never emits the phase, so behavior with the shipped binary is unchanged.
2026-10-14 23:10:35 -07:00
winewill 29221675a2 fix(desktop): clarify source rejection and isolate Git fixtures 2026-10-15 14:03:50 +08:00
Yichen Jiang 02d25c695e docs(models): clarify the reasoning Off copy and level reset rule
The help tooltip now says that Off needs no value but keeps and sends a
value already written in the configuration, matching the disabled field
that shows it. Both READMEs add the rule that a custom value carries over
only between two thinking levels, so changing a row to or from Off resets
it. The Models settings e2e header comment is rewrapped.
2026-10-15 13:54:27 +08:00
Yichen Jiang 034f2cf8bd Merge pull request #5935 from deepseek-harness/worktree/command-delete-as-whole-21017f
feat(web): claim slash commands as atomic composer chips
2026-10-15 13:42:27 +08:00
winewill 61059baf61 feat(desktop): allow repository branches for test packaging 2026-10-15 13:39:28 +08:00
Yichen Jiang 6f8b55f3eb fix(web): restore a claimed command chip after reload
The persisted draft now records the leading command chip's claim token
(DraftSnapshot.command). Restoring the draft asks the trigger pipeline's
new resolveClaim, which polls the optional InputTriggerSource.matchClaim
hook: the command source awaits its catalog and answers only a
leadingInput claim, never opening a popup, running an action, or
executing the command. The input rebuilds the chip once the claim
resolves, only at the revision the restore produced and merged into the
seed's history entry; otherwise the literal token stays and Enter still
adjudicates it.
2026-10-15 13:18:01 +08:00
Yichen Jiang f61d9982f8 feat(models): edit reasoning levels of custom model API models
A model row on a hand-declared pi-ai route now edits its reasoningEfforts
from the Models settings page instead of only through cordis.patch.yml.
A switch turns the field on or off; when on, each row pairs a level
select with the API value sent for it, which defaults to the level name.
Off only turns reasoning off, so its value field is disabled and a value
already stored for it is kept. Saving refuses a list without a level
besides off, and a help tooltip beside the label explains the values.
Catalog routes and the DeepSeek card are unchanged.

Refs #6260
2026-10-15 12:56:11 +08:00
Yudong Han 755c52fd77 Merge pull request #6096 from deepseek-harness/fix/office-operation-guidance
fix(office): 回复不嵌入已删除预览,PDF 导出不查找阅读器
2026-10-15 12:23:26 +08:00
imccyu 5026df8fd1 Merge pull request #6250 from deepseek-harness/worktree-optbundles
feat(webworker): offer optional bundles in the preview and fetch lazy chunks on demand
2026-10-15 12:17:34 +08:00
yudshj dc9119c00b Merge remote-tracking branch 'origin/master' into pr6096-merge
# Conflicts:
#	packages/skill/skill-office/package.json
2026-10-15 12:04:01 +08:00
imccyu 6e43ad6bb1 Merge pull request #6164 from deepseek-harness/fix/subprocess-linux-probe-once
fix(subprocess-local): probe Linux native containment once and support systemd before v254
2026-10-15 11:32:27 +08:00
imccyu 18a22f73b2 Merge pull request #6228 from deepseek-harness/worktree-versionmismatch
feat(client-modules): prompt to reload when the host release changes
2026-10-15 11:31:54 +08:00
imccyu 93d2ad2d25 feat(webworker): boot the preview from the shipped profile and fetch lazy chunks on demand
The packer images the web template bundles, every optional bundle, and an installation manifest instead of a dumped composition, and the Worker boots that profile through app-boot like dsh web. Package-local lazy client chunks move into content-addressed archives that the VFS fetches on first asynchronous read; client-modules reads chunks asynchronously.

The Worker replaces node-addon-require-builtin with Node v24.18.0 internal-module interfaces and reports Node 24, so plugin metadata, the Cordis Loader, and HMR reach one cascaded loader backed by the Worker loader. Resolution failures carry Node error codes, the empty load cache lets HMR apply profile changes, and every optional bundle switches on and off live. Platform stubs throw ERR_FEATURE_UNAVAILABLE_ON_PLATFORM.
2026-10-15 11:30:45 +08:00
Turtle 15c245f2d8 Merge pull request #5633 from deepseek-harness/turtle/plugin-market-generator
feat(plugin-market-generator): add a reference index generator
2026-10-15 05:57:24 +08:00
turtle1999 14abe9543c feat(plugin-market-generator): record the source repository and parse options with commander
Copy each plugin's manifest repository as its web page and package
directory, converting shorthands and Git URLs with hosted-git-info. Parse
options and generate help with commander.
2026-10-15 05:37:54 +08:00
turtle1999 434dc116f0 feat(plugin-market-generator): add a reference index generator
Generate a validated format-1 plugin market index from a list of npm package
names and https Git repository URLs, without running package code.

- List every non-deprecated npm bundle version, and Git versions at release
  tags and the default branch, each with its engines.dsh range.
- Read display fields, keywords, homepage, and icons the way DSH reads
  installed plugins; inline small icons up to the format's 32 KiB limit.
- Cache results per immutable input and rewrite output only when it changes.
2026-10-15 05:37:54 +08:00
turtle1999 ed92c867ed feat(plugin-market): name the plugin and version in a Discover install
A Discover install passes the entry's localized title and version with its
spec. Every install dialog screen names the plugin and version, and shows the
spec, such as a pinned git+https URL, below in smaller monospace text.
2026-10-15 05:37:44 +08:00
turtle1999 b227127d62 fix(plugin-market): keep the Installed tab panel and list the markets bundle under More
The Installed tab panel now wraps its loading, unavailable, and empty states.
Custom plugin markets joins the bundles the More page holds back until the
profile enables them, and the repository field and its directory rule are
documented.
2026-10-15 05:37:44 +08:00
turtle1999 ab3bb74550 feat(plugin-market): carry a plugin's source repository
Indexes may give each plugin an optional repository: the web URL of its
source repository and the package directory inside it. The Discover entry
page links it as Source code, and the plugin_market tool returns it.
2026-10-15 05:37:44 +08:00
turtle1999 3de3a977e0 feat(plugin-market): add the Custom plugin markets bundle and Discover tab
Read configured plugin market indexes on the Host, search them with fuzzy
multilingual matching, and install a chosen version from the Plugins page.

- dsh-plugin-market-index validates format-1 indexes and builds install
  arguments; inline icons are capped at 32 KiB.
- The pluginMarket service fetches configured http(s) URLs, parses bodies in
  a Worker Thread, caches documents, and pages filtered, ordered results with
  installation status and engines.dsh compatibility.
- Discover is an opt-in experimental bundle; the Plugins page gains a
  plugins.tab slot and confirmed installs.
- The plugin_market tool lists indexes, plugins, and versions for Creator mode.
- Plugin compatibility reads engines.dsh before DSH peer dependencies.
2026-10-15 05:37:44 +08:00
imccyu bf1a2bf69c fix(inspector): serve the Client bootstrap while the Inspector Worker starts
A Client plugin loaded after the page starts fetches its bootstrap once. The Host now registers that route before starting the Worker and answers once the Worker is ready, so enabling Developer Tools on a running page opens the bottom DevTools without a reload. A failed start answers 503.

When the platform cannot run the Worker (ERR_FEATURE_UNAVAILABLE_ON_PLATFORM), the Host warns once and stays active with only the 503 bootstrap route; serve-static and open load after the Worker starts. Every other startup failure still fails the fiber.
2026-10-15 03:56:26 +08:00
imccyu 22c51728ed fix(dependencies): check experimental packages under the published dependency policy
verify-package-dependencies now covers every packages/*/* manifest, experimental included, and reads each package's tsdown build entries so Worker and child-process entries count as Host runtime sources. Experimental Client packages no longer declare browser and type relationships as peers.
2026-10-15 03:56:26 +08:00
imccyu b77cc1c0f2 feat(gates): reject module-scope await in package sources
Package modules that other code imports must not suspend evaluation at module scope. A syntax-aware gate scans packages/*/*/src and excludes only executables (a shebang or a package bin target). The SenseVoice worker, the SSH helper entry, and the Inspector worker entry now run an explicit main().
2026-10-15 03:56:25 +08:00
imccyu ba090a4b59 fix(ui-settings-general): focus Not now when the release prompt opens 2026-10-15 02:26:28 +08:00
imccyu a7a40f93d9 fix(client-modules): compare host releases before parsing the graph 2026-10-15 01:35:28 +08:00
imccyu f52abc04fa feat(client-modules): apply no plugin update from another host release 2026-10-15 00:51:20 +08:00
imccyu da5e23e8e8 refactor(subprocess-local): decide Linux containment in a testable selector
The provider's Linux decisions now live in LinuxScopeSelector, a state machine
over injected observations: the deep probe, the user-manager listener, the
manager confirmation, and the systemd-run binary identity. Each decision
carries its reason and probe trigger, the runtime logs it at debug level, and
table-driven scenarios cover every transition.

- A positive verdict probes again when the systemd-run found through PATH
  changes device, inode, size, or modification time, so an in-place upgrade
  past v254 cannot reuse options that no longer keep argv literal.
- The listener is read before the probe, so a manager that starts during a
  failed probe counts as a new listener.
- The kernel socket table is decoded as UTF-8, matching runtime directories
  outside ASCII.
- LinuxScopeNotStartedError drops its unused code field.
2026-10-15 00:51:05 +08:00
imccyu 71de43d8ee fix(subprocess-local): probe Linux again after a scope never starts or a new manager listens
A cached positive verdict let a hung user manager that still listens fail every
later command, and a cached negative verdict ignored a manager that started
later until the provider restarted.

- A scope launch whose bootstrap never consumed its launch request now fails
  with LinuxScopeNotStartedError, and the runtime clears the verdict it
  launched under, so the next eligible spawn probes again and records the
  fallback when that probe fails. A generation counter keeps launches from a
  superseded probe from forcing another probe.
- After a negative verdict, the runtime records the /proc/net/unix listener on
  $XDG_RUNTIME_DIR/systemd/private and probes again only when a different
  listener, meaning a new manager instance, appears.
2026-10-15 00:14:50 +08:00
07akioni 7ba5e3a01f Merge pull request #6197 from deepseek-harness/07akioni/desktop-environment-telemetry
feat(telemetry): attach cached desktop environment to product events
2026-10-14 21:29:33 +08:00
Yichen Jiang 571902028a Merge pull request #6241 from deepseek-harness/worktree/sync-dsh-0.2.1-alpha.3-to-master
release(dsh): sync 0.2.1-alpha.3 back to master
2026-10-14 21:15:15 +08:00
Yichen Jiang 5d7d13289c test(web): decouple plugin snapshots from release versions 2026-10-14 20:59:15 +08:00
Yichen Jiang 853b1d3609 Merge master into DSH 0.2.1-alpha.3 synchronization branch 2026-10-14 20:34:43 +08:00
winewill c7ab529c34 fix(desktop): preserve recovery paths in installer dialogs 2026-10-14 20:11:41 +08:00
winewill 06e5024577 fix(desktop): bound expanded directory failure details 2026-10-14 19:58:21 +08:00
imccyu 514e8893b5 test(subprocess-local): harden the socket-table checks and state their scope
The real-socket test rejects on a listen error instead of hanging, the table
fixture comment names the accepted-connection row it models, and the remaining
linux-scope module mocks provide confirmLinuxManager. The README notes that
the socket-table read matches only listeners in the same network namespace and
costs time proportional to the table size.
2026-10-14 19:53:13 +08:00
imccyu ac5a3e336c test(ui-settings-general): read the release prompt inject face without an unknown cast 2026-10-14 19:39:47 +08:00
winewill dcc8df8320 fix(desktop): preserve reported recovery state and label retry attempts 2026-10-14 19:38:42 +08:00
imccyu c97f840c89 fix(subprocess-local): confirm the user manager from its listening socket
systemd closes the private listening socket on shutdown without unlinking its
path, so the socket file outlives a manager stopped in place. The fast path now
reads /proc/net/unix, where the kernel lists a bound socket only while a
process holds it open, and accepts the manager only when a process listens on
$XDG_RUNTIME_DIR/systemd/private; otherwise the systemctl query decides. The
scope startup error names an unreachable manager as the cause, and the spawn
test again asserts that Windows launches never reach the fallback.
2026-10-14 19:34:14 +08:00
imccyu 055c7d7a74 fix(subprocess-local): confirm the user manager before each cached Linux scope launch
Without a per-spawn check, a user manager that stops after a positive probe,
such as after a logout without lingering, made every later scope launch fail.
Each eligible spawn now confirms the manager: its socket file at
$XDG_RUNTIME_DIR/systemd/private disappears when the manager stops, so its
presence answers with one stat; otherwise the restored systemctl --user show
query decides and the spawn falls back while it fails. A crashed or hung
manager that leaves its socket file still fails the scope launch with the
existing restart hint.
2026-10-14 19:14:29 +08:00
winewill fae05edca7 test(desktop): share retry trace reads with the installer 2026-10-14 19:13:46 +08:00
imccyu 7c9e54b3ef feat(client-modules): prompt to reload when the host release changes 2026-10-14 19:11:22 +08:00
imccyu f12bf31c4a docs(notes): record the host release change refresh prompt 2026-10-14 19:10:48 +08:00
winewill 14819e2eb4 fix(desktop): retry Windows installer directory locks and report failures 2026-10-14 19:08:02 +08:00
winewill fe3966734c Merge pull request #6212 from deepseek-harness/build/desktop-approval-summary
feat(desktop): show complete publication approval summary
2026-10-14 18:57:31 +08:00
imccyu c218e79102 docs(subprocess-local): name the systemd 236 floor for --collect
The probe always passes --collect, which systemd-run rejects before v236, so those hosts use the fallback regardless of the --expand-environment=no retry.
2026-10-14 18:05:02 +08:00
Dudu-0223 4e4ac0eae8 Merge pull request #6208 from deepseek-harness/topic/agent-team-prompt
feat(agent-team): simplify team availability prompt
2026-10-14 17:55:36 +08:00
imccyu d73ad23353 fix(subprocess-local): name the recovery when a cached Linux scope fails to start
With the probe cached for the provider lifetime, a user manager that stops
after a positive probe makes every later scope launch fail. The startup error
now says to restart the provider to probe native containment again. The README
no longer lists older user-systemd among fallback hosts and notes the second
probe attempt for a systemd-run that rejects --expand-environment=no.
2026-10-14 17:50:29 +08:00
Ziya 075ebbb9f3 Merge pull request #6179 from deepseek-harness/ziya/builtin-plugin-preview-20261014
feat(plugins): 规范内置配图并补充双语语音示例
2026-10-14 02:46:15 -07:00
imccyu 39874e62ba docs(subprocess-local): state the provider-lifetime Linux probe and its cost
The README says the provider also keeps a failed or timed-out first probe and
names the user manager as reachable rather than live, since it is checked once.
The probe cache documents why no later spawn repeats the probe, and the spawn
test now starts two Windows commands to match its per-spawn Job recheck title.
2026-10-14 17:32:15 +08:00
ZiyaZhang d6e3df155e chore(plugins): preserve original preview work on the adopted frontend implementation 2026-10-14 02:25:24 -07:00
imccyu 0fffa2432a fix(subprocess-local): support systemd before v254 for native scopes
systemd-run before v254 rejects --expand-environment=no, so the scope probe
always failed and those hosts used the weaker fallback. Its scope mode never
expands argv, so the probe retries without the option only when systemd-run
reports it as unrecognized, and scope launches reuse the probed options.

systemctl before v252 rejects --kill-whom. Use --kill-who, the older
spelling, which newer systemctl accepts as an unambiguous prefix.
2026-10-14 17:24:14 +08:00
imccyu 404e64b435 fix(subprocess-local): probe Linux native containment once per provider
Every Linux spawn() and spawnTerminal() ran a synchronous spawnSync probe
before launching the command: the full systemd-run scope probe until it first
succeeded (a failed verdict was never cached), then a systemctl manager recheck.
spawnSync blocks the host event loop until the probe exits, so hosts without a
usable user manager paid the full probe on every grep, glob, and shell command.

Keep the first verdict, positive or negative, for the provider lifetime and
drop the per-spawn manager recheck.
2026-10-14 17:24:14 +08:00
ZiyaZhang c1269a8f75 feat(plugins): standardize built-in previews and illustrate voice input 2026-10-14 02:19:42 -07:00
winewill 1bd032be88 fix(desktop): align approval links with normalized upload destinations 2026-10-14 17:10:19 +08:00
winewill 24d73cae8d feat(desktop): show complete publication approval summary 2026-10-14 16:57:28 +08:00
ZiyaZhang ade9979185 fix(plugins): restrict feature previews to bilingual built-in SVGs 2026-10-14 01:57:03 -07:00
Dudu-0223 a04b8ef7e2 feat(agent-team): simplify team availability prompt 2026-10-14 16:36:16 +08:00
07akioni 04c9489065 fix(telemetry): handle native metadata edge cases and scalar failures 2026-10-14 16:10:40 +08:00
ZiyaZhang 34252e34d0 docs(plugins): define built-in feature preview guidelines 2026-10-14 00:27:53 -07:00
07akioni 4dee41f4bc feat(telemetry): attach cached desktop environment to product events 2026-10-14 15:23:58 +08:00
ZiyaZhang 37dbd15e0c feat(plugins): localize optional feature previews 2026-10-13 23:55:11 -07:00
Shigma c0bf2ac81d docs(cordis): plan the staged rc.11 vendor upgrade 2026-10-14 14:28:16 +08:00
ZiyaZhang fb752c7763 style(plugins): refine badge feature illustration 2026-10-13 23:07:32 -07:00
ZiyaZhang e527c04663 feat(plugins): support optional feature previews in bundle details 2026-10-13 22:22:43 -07:00
Yichen Jiang 2182e3ad09 Merge remote-tracking branch 'origin/master' into worktree/command-delete-as-whole-21017f
# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
2026-10-14 10:58:07 +08:00
yudshj f12fa3bad1 fix(office): keep render replies and PDF checks within available files
- office-render: report inspection results in text and never link or embed
  removed previews. Desktop and Web replies otherwise embed deleted preview
  images, because their file-reference guidance encourages inline images.
- office-export-pdf: validate with the command result, nonempty output, and
  %PDF- signature; do not search for PDF readers unless the user requests
  deeper checks. The bundled runtime has none, so the old conditional made
  the model probe system Python and command-line tools.
- Assert that the PDF export e2e does not probe PDF reader tools.
- Replace stale "structural checks" wording in the Desktop Office plugin
  JSDoc and the skill package description.
2026-10-13 13:55:17 +08:00
Yichen Jiang 5e3b3509b8 Merge remote-tracking branch 'origin/master' into worktree/command-delete-as-whole-21017f
# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
2026-10-11 17:11:17 +08:00
Yichen Jiang 75b761f87e Merge remote-tracking branch 'origin/master' into worktree/command-delete-as-whole-21017f
# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
2026-10-10 13:02:14 +08:00
Yichen Jiang f20a0190ba Merge remote-tracking branch 'origin/master' into worktree/command-delete-as-whole-21017f
# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
2026-10-09 16:24:05 +08:00
Yichen Jiang 510cd4cdcc fix(web): cover every deletion path in two-step chip deletion
Decide the command chip's selecting step on deletion input events as well
as the character-deletion command, so iOS ranged deleteContentBackward no
longer removes the chip in one press. A selected chip now clears only
after a keydown that produced no character deletion, which keeps platform
shortcuts such as macOS Ctrl+H working.

A failed command submission settles to the leading chip like every other
transition, so submit-settled no longer carries the draft. The claim event
reuses the same handler. The seat rule runs as chip and paragraph
transforms instead of walking the whole document on every update.

Document the reload limitation under Known Limitations and assert that a
chip ending its block does not grow the composer.
2026-10-09 14:52:21 +08:00
Yichen Jiang 98d93db18f feat(web): claim slash commands as atomic composer chips
A claimed command is now a CommandChipNode on the draft's leading seat
instead of styled literal text. The submit machine follows the chip: it
holds the claim while the chip leads the document, releases it when the
chip is deleted or displaced, and re-enters claimed when history
restores the chip. Deleting at the chip's edge takes two steps: the
first Backspace or Delete selects the chip, the next removes it.

The claim-token decoration transform and the text-reference precedence
rule are removed with the literal token.
2026-10-09 13:38:12 +08:00
1388 changed files with 34933 additions and 3996 deletions

No files matched your search

@@ -18,8 +18,8 @@
en: b432f80a424c3e49
zh: faa26925fdd98387
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/provider-availability-and-selection:
en: dff78c46432311a1
zh: 58d5db4c3c85b886
en: 4e4ec068c2a22383
zh: b7030d0f00bfdb74
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/search-request-and-result-schema:
en: ec3703b7e7c6b23d
zh: 325755e6e90092da
@@ -60,6 +60,7 @@ flowchart LR
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
@@ -151,6 +152,8 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`.
Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Web search settings switch relies on this to move both capabilities to the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md) at once.
`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider.
## Search request and result schema
@@ -60,6 +60,7 @@ flowchart LR
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
@@ -151,6 +152,8 @@ interface WebRuntime {
运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。
每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即对下一次操作选择另一个提供方。网页搜索设置页的开关借此把两种能力同时切换到 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)。
`ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。
## 搜索请求与结果 schema
@@ -27,8 +27,8 @@
en: 37e2fa1fe5cb2b95
zh: 80e513db87709cb4
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/hot-reload-one-driver-plugin-self-watched-bundles:
en: f8b591d4c41ef492
zh: bc70f1cd540b7753
en: c1fdcb3e6f074a7a
zh: b08c7b1ff25acb09
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/package-ownership:
en: 94ca0d09e6fd1e3a
zh: d5949a2bdec795ba
@@ -94,7 +94,7 @@ On the browser side, the transport delegates code replacement to the same module
Bootstrap replacement is rejected before invalidation or teardown: the module system retains its initial exports, so remounting its old code would reset consumers without applying the requested revision. The page reports the required reload and preserves the bootstrap fiber. Every non-bootstrap plugin shares the replacement semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a foundational provider such as connection re-loads every dependent through cordis itself — correct, if heavy.
Reload creates a fresh fiber and component state; it does not preserve React state inside the replaced plugin. Static assembly libraries and the shell require a rebuilt page. Failed imports or activation remain diagnosable and retryable without rolling back unrelated plugins. Self-reload closes the old SSE channel and opens a new one; its full snapshot repairs missed graph changes. Boot, graph updates and rebuilt frames share the same queue, so a code replacement cannot overlap initial module arrival.
Reload creates a fresh fiber and component state; it does not preserve React state inside the replaced plugin. Static assembly libraries and the shell require a rebuilt page; when the Host itself serves another release, the page holds plugin replacement and asks whether to reload ([Host release change prompt](../feature/2026-10-14-host-release-change-refresh-prompt.md)). Failed imports or activation remain diagnosable and retryable without rolling back unrelated plugins. Self-reload closes the old SSE channel and opens a new one; its full snapshot repairs missed graph changes. Boot, graph updates and rebuilt frames share the same queue, so a code replacement cannot overlap initial module arrival.
## Package ownership
@@ -94,7 +94,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始导出,重新挂载旧代码会重置消费者,却无法应用请求的 revision。页面会报告需要刷新,并保留 bootstrap fiber。所有非 bootstrap 插件都共享同一套替换语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。
重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面;Host 本身换成另一个 release 时,页面会暂停插件替换并询问是否刷新([Host release 变化提示](../feature/2026-10-14-host-release-change-refresh-prompt.zh.md))。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。
## 包归属
@@ -9,8 +9,8 @@
en: 2eeb916eb65c66e7
zh: 7bf5c0abb8bb61e4
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/decision:
en: 42e800c4cd3b6ebe
zh: 06fabe5a9681fa27
en: 0125e33ba966c8ac
zh: bf6af389336999af
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/alternatives-considered:
en: 7c7880fdb0cc95f6
zh: 2b8a539192626789
@@ -12,8 +12,8 @@ The account route's catalog was deployment configuration: a build advertised the
- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.md) loads the listing lazily for each credential, waits for it once up to `modelsTimeoutMs`, refreshes it in the background after `modelsMaxAgeMs`, keeps the last good listing when a load fails or returns no model, and serves the bundled catalog before any listing loads. The cache lives in memory and is keyed by endpoint and token fingerprint, so per-account rollouts stay separate.
- A listed model is available when its `client_requirements["deepseek-harness"].min_version` is at most the current DeepSeek Harness version in semantic-version order. Without that declaration, only ids the built-in catalog knows are available; the others stay hidden, because a client cannot tell which future version supports them. A request for a refused listed model fails before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED`.
- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge such as tool-update mode and image budgets; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it.
- The shared transport gains only two provider hooks, `connect` and `admit`; listing, gating, and merging stay in the account package.
- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge the listing does not declare, such as image budgets and a protocol mode the listing omits or sets to `null`; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it.
- The shared transport gains only two provider hooks, `connect` and `admit`. Listing, gating, and merging live in the shared transport, which the [official route](2026-10-22-deepseek-official-model-listing.md) also uses.
## Alternatives considered
@@ -12,8 +12,8 @@ Status: implemented
- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.zh.md) 按凭据懒加载列表,首次访问最多等待 `modelsTimeoutMs`,超过 `modelsMaxAgeMs` 后在后台刷新;获取失败或返回的列表中没有模型时保留上一份,还没有加载到任何列表时使用内置目录。缓存只在内存中,以端点和 token 指纹为键,因此按账号灰度互不干扰。
- 列出的模型在其 `client_requirements["deepseek-harness"].min_version` 按 semver 顺序不高于当前 DeepSeek Harness 版本时可用。没有这个声明时,只有内置目录认识的 id 可用,其余保持隐藏,因为客户端无法判断将来哪个版本会支持它们。请求被拒绝的已列出模型时,在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 失败。
- 列表中的能力逐字段覆盖内置能力;内置条目保留工具更新模式、图片预算等客户端知识;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。
- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子;列表、门控与合并都留在账号包中。
- 列表中的能力逐字段覆盖内置能力;内置条目保留列表没有声明的客户端知识,例如图片预算,以及列表没有给出或设为 `null` 的协议模式;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。
- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子。列表、门控与合并位于共享传输包中,[官方路由](2026-10-22-deepseek-official-model-listing.zh.md)也使用它们。
## 考虑过的替代方案
@@ -9,14 +9,14 @@
en: 6aa396e12264ee48
zh: 56849a18acaf830a
/agent-note-initial-draft-content-and-structured-reference-restoration/decision:
en: 9cf2afd9d5e663e9
zh: ad59b1bec6ec4666
en: 1a370f23ee32b6a4
zh: fb969ec929a6c7c9
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-model:
en: 7f33895364a9f5cc
zh: f1c79ea15f5c151e
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/classes-and-methods:
en: ca81423a57f898a0
zh: 98962cc1c6bce113
en: e57394545d9d93ff
zh: 5ab142e661750b82
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-flow:
en: dd57e639e9328f1d
zh: 5e16cb2e3a73597c
@@ -12,7 +12,7 @@ New-task entry points need to place unsent content in the composer. A draft cont
Keep the existing `SessionInputShell`, `DraftEditorRuntime`, Conversation store, and Session reference lifetime. Plain text and structured drafts enter through the input model. Initial import finishes before `InputHub.shellFor()` returns, without React effects restoring text. Existing input objects are not restored again. React mounts the same editor and binds the existing persistence callback.
The second parameter of `startSession(workspaceId?, options?)` contains `prompt` and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. Without clearing, existing content is preserved and only an empty draft adopts the prompt. Explicit clearing replaces the target Session's text document, without deleting attachments or changing other Sessions. Prefilling does not send, execute commands, or change `blank`.
The second parameter of `startSession(workspaceId?, options?)` contains `prompt`, `whenDraftExists`, and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. An empty draft adopts the prompt. When the target already has text or attachments, `whenDraftExists` decides: `keep` (the default) preserves it, `replace` replaces the target Session's text document with the prompt or clears it without one, and `append` adds the prompt as new paragraphs after the existing text and references. Neither deletes attachments or changes other Sessions. `clearPreviousDraft: true` acts as `replace` only when `whenDraftExists` is omitted. Prefilling does not send, execute commands, or change `blank`.
### Data model
@@ -41,7 +41,7 @@ The existing `dsh.conversation.<sessionId>` key remains. Readers accept both leg
There is no new draft service, storage key, general task framework, or Session creation entry point. SlotRenderer, Host Session logs, command execution, and file-discovery RPCs are unchanged. `InputActions` remains the shared input-action entry; components receive no separate content-initialization callback.
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` aliases `DraftInitializationOptions`, whose fields are `prompt?: string` and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` extends `DraftInitializationOptions`, whose fields are `prompt?: string`, `whenDraftExists?: 'replace' | 'append' | 'keep'`, and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
### Data flow
@@ -12,7 +12,7 @@ Status: implemented
保留现有 `SessionInputShell`、`DraftEditorRuntime`、Conversation store 和 Session 引用生命周期。普通文字与结构化草稿统一经过输入模型导入;初始导入在 `InputHub.shellFor()` 返回对象前完成,不由 React effect 恢复文字。已存在的输入对象不重新恢复。React 只挂载同一编辑器,并绑定现有持久化回调。
`startSession(workspaceId?, options?)` 的第二参数保留 `prompt` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。不清空时保留已有内容,空稿才采用 prompt;显式清空作用于目标 Session 的文字文档,不删除附件,也不修改其他 Session。预填不自动发送、执行命令或改变 `blank`。
`startSession(workspaceId?, options?)` 的第二参数包含 `prompt`、`whenDraftExists` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。空稿直接采用 prompt。目标已有文字或附件时由 `whenDraftExists` 决定:`keep`(默认)保留原稿,`replace` 用 prompt 替换目标 Session 的文字文档、无 prompt 时清空,`append` 在原有文字与引用之后另起段落追加 prompt。三者都不删除附件,也不修改其他 Session。只有未传 `whenDraftExists` 时,`clearPreviousDraft: true` 才等同 `replace`。预填不自动发送、执行命令或改变 `blank`。
### 数据模型
@@ -41,7 +41,7 @@ Status: implemented
不新增草稿服务、存储 key、通用任务框架或另一套 Session 创建入口;不改 SlotRenderer、Host Session 日志、命令执行和文件发现 RPC。`InputActions` 保留统一输入操作入口,组件不单独注入另一条内容初始化回调。
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 直接引用 `DraftInitializationOptions`,字段为 `prompt?: string` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 扩展 `DraftInitializationOptions`,字段为 `prompt?: string`、`whenDraftExists?: 'replace' | 'append' | 'keep'` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
### 数据流
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-09-composer-command-chip.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 .agents/notes/implemented/architecture/2026-10-09-composer-command-chip.md
/agent-note-claimed-commands-as-atomic-composer-chips:
en: 66d4f149698760c8
zh: de219f394dfd4a4c
/agent-note-claimed-commands-as-atomic-composer-chips/problem:
en: 831c97e0de131639
zh: ea35b4b179ff33f0
/agent-note-claimed-commands-as-atomic-composer-chips/decision:
en: 6ac53f6cd1307897
zh: 2f1dbe762b52f18e
/agent-note-claimed-commands-as-atomic-composer-chips/alternatives-considered:
en: 9c694a21237d7772
zh: b35ba7bf8b73b679
/agent-note-claimed-commands-as-atomic-composer-chips/consequences:
en: 089365d6a4be0ebb
zh: b30384c717a7ea1d
@@ -0,0 +1,41 @@
# Agent Note: Claimed commands as atomic composer chips
Status: implemented
English | [中文](2026-10-09-composer-command-chip.zh.md)
## Problem
The [Lexical composer decision](../../archived/architecture/2026-08-20-web-composer-lexical-editor.md) kept a claimed command as literal leading text. A TextNode transform styled the token and split typed overflow back out of it, and the submit machine held the claim while the draft string started with the token. Every edit at the token boundary ran through that transform. Lexical merges same-style neighbours before the overflow node's own transform runs, so the split and the merge looped until Lexical aborted the update with error #14 and the DOM diverged from the editor state (issue #4490). PR #5480 cleared the copied style for the reported gesture; pasting an image after a claim and IME composition reach the same transform and remained unverified. The plain-text reference entity also needed a precedence rule to leave the claimed token alone.
## Decision
A claim replaces the leading trigger span with `CommandChipNode`, an atomic inline `DecoratorNode`, followed by the token's argument separator as an ordinary text node.
- The node carries the `CommandClaim`, including its submit closure. History restores nodes with their fields, so undoing a chip deletion restores the claim. JSON export writes the token text; JSON import yields a plain text node because the closure has no JSON form.
- The detect projection counts the chip as one U+FFFC, so trigger detection never re-reads the command name. The clipboard projection is the token without its separator. The chip is not a reference occurrence. `EditorProjection.command` is the claim of a chip that opens the document.
- Outside a submit transaction, the machine's claim follows the document: `draft-changed` carries the leading chip's claim, so deleting or displacing the chip releases the claim and a history restore re-enters claimed. A failed command submission settles to the same rule, so it returns to claimed exactly when a chip still leads; a claim adjudicated from typed text at Enter has no chip and returns to plain.
- Node transforms keep the chip on the leading seat. A chip with a preceding sibling or inside a later block becomes its literal token, which releases the claim as typing before the old token did. The chip's own transform sees sibling insertions and moves between blocks; a paragraph transform sees a block inserted before the chip's block, so no update walks the whole document.
- Deletion takes two steps. A character deletion that reaches the chip from a collapsed caret marks it selected (`data-selected` on its host; the root's `data-composer-chip-selected` hides the caret), and the next deletion in the same direction removes it whole. Deletions are decided both on the character-deletion command and on deletion input events, because iOS applies a ranged `deleteContentBackward` without that command. A key whose dispatch produced no character deletion, a click, blur, or a caret that leaves the chip clears the mark, so platform deletion shortcuts such as macOS Ctrl+H keep it. The editor selection stays a collapsed range and `isKeyboardSelectable()` stays false for the reason recorded on reference chips. Escape still does not release a claim.
- The persisted draft records the leading chip's claim token (`DraftSnapshot.command`), never the claim. On restore the input asks the trigger pipeline's `resolveClaim`, which polls the optional source hook `matchClaim`: it awaits the source's catalog and answers only a claim, never invoking or executing the command, unlike Enter's `matchEnter`. The chip is rebuilt only at the revision the restore produced and merged into the seed's history entry; an earlier edit, an unclaimed token, or a catalog failure keeps the literal token, which Enter still adjudicates.
- `claim-decor.ts` and the text-reference precedence rule are deleted.
The `slash/input-*` events, `CommandClaim`, `InputState.claim`, and trigger sources keep their fields. `CommandClaim.token` still ends with the separator, and command arguments are still read from the clipboard text after it.
## Alternatives considered
**Keep literal text and repair each gesture.** Every gesture that reaches the boundary node needs its own proof, as #5480 showed, and the precedence rule with text references remains.
**Reuse `ReferenceChipNode`.** Reference chips are occurrences serialized through their owner codec at submit; a command is not model content and must not enter `occurrences`.
**Put the separator inside the chip text.** Lexical appends a managed line break after an inline decorator that ends its block, so the blank-arguments hint rendered by `p:last-child::after` would move to the next line. A separate text node keeps the hint on the chip's line.
**Select the chip with a NodeSelection.** The plain-text binding's arrow, typing, and deletion handlers ignore non-range selections, so each of those gestures would need its own handling at the chip.
**Keep claims in a shell map keyed by NodeKey.** The map duplicates the field the node already carries and needs pruning whenever history is cleared.
## Consequences
- Backspace no longer edits the command name character by character to reopen the menu. At the chip's edge, two Backspace or Delete presses remove the chip and leave the arguments as plain text.
- A reload restores the chip after the command catalog loads; until then, and when restoration does not apply, the draft shows the literal token.
- Whole-chip deletion through a real key event is observable only in the browser lane because jsdom lacks `Selection.modify`; `lifecycle-chrome.e2e.ts` pins the separator, select, and delete presses.
@@ -0,0 +1,41 @@
# Agent Note: 已认领命令作为输入框原子 chip
Status: implemented
[English](2026-10-09-composer-command-chip.md) | 中文
## 问题
[Lexical 输入框决策](../../archived/architecture/2026-08-20-web-composer-lexical-editor.md)把已认领命令保留为行首字面文本。一个 TextNode transform 给 token 加样式,并把输入进去的溢出文字拆出来;提交状态机在草稿字符串以 token 开头时保持认领。token 交界处的每次编辑都会经过这个 transform。Lexical 会在溢出节点自身的 transform 执行前合并样式相同的相邻节点,于是拆分与合并循环往复,直到 Lexical 以错误码 14 中止这次更新,DOM 与编辑器状态随之分叉(issue #4490)。PR #5480 针对报告的手势清除了被复制的样式;认领后粘贴图片和 IME 组字会进入同一个 transform,仍未验证。纯文本引用实体也需要一条优先级规则来避开已认领的 token。
## 决策
认领把行首触发区间替换为 `CommandChipNode`(一个原子 inline `DecoratorNode`),其后是作为普通文本节点的 token 参数分隔符。
- 节点携带 `CommandClaim`,包括其 submit 闭包。历史记录恢复节点时连同字段一起恢复,因此撤销 chip 删除会恢复认领。JSON 导出写入 token 文本;闭包没有 JSON 形式,因此 JSON 导入得到纯文本节点。
- 检测投影把 chip 计为一个 U+FFFC,触发检测不会再读到命令名。剪贴板投影是不含分隔符的 token。chip 不是引用 occurrence。`EditorProjection.command` 是位于文档开头的 chip 所携带的认领。
- 在提交事务之外,状态机的认领跟随文档:`draft-changed` 携带行首 chip 的认领,因此删除或挤开 chip 会释放认领,历史恢复会重新进入 claimed。命令提交失败后按同一规则结算,只要行首仍有 chip 就回到 claimed;Enter 时从输入文本裁决出的认领没有 chip,回到 plain。
- 节点 transform 让 chip 保持在行首位置。前面有兄弟节点或位于后续块中的 chip 会变回字面 token,并像在旧 token 前输入那样释放认领。chip 自身的 transform 覆盖兄弟节点插入和跨块移动;段落 transform 覆盖在 chip 所在块之前插入块的情况,因此没有任何更新需要遍历整个文档。
- 删除分两步。从折叠光标出发、方向触及 chip 的字符删除先把它标记为选中(宿主元素上的 `data-selected`;根元素的 `data-composer-chip-selected` 隐藏光标),同方向的下一次删除才整体移除它。字符删除命令和删除类输入事件都会参与判断,因为 iOS 处理带范围的 `deleteContentBackward` 时不经过该命令。分发过程中没有产生字符删除的按键、点击、失焦或光标离开 chip 会清除标记,因此 macOS Ctrl+H 这类平台删除快捷键会保留它。编辑器选区保持为折叠的 range,`isKeyboardSelectable()` 仍为 false,原因与引用 chip 记录的相同。Escape 仍不释放认领。
- 持久化的草稿记录行首 chip 的认领 token(`DraftSnapshot.command`),从不保存认领本身。恢复时输入框调用触发管线的 `resolveClaim`,它轮询可选的 source 钩子 `matchClaim`:等待 source 的目录就绪,只应答认领,与 Enter 的 `matchEnter` 不同,从不调用或执行命令。chip 只在恢复产生的那个版本上重建,并合入种子的历史条目;在此之前发生的编辑、无人认领的 token 或目录加载失败都会保留字面 token,Enter 仍会对其裁决。
- 删除 `claim-decor.ts` 和文本引用的优先级规则。
`slash/input-*` 事件、`CommandClaim`、`InputState.claim` 与触发源的字段保持不变。`CommandClaim.token` 仍以分隔符结尾,命令参数仍从其后的剪贴板文本读取。
## 考虑过的替代方案
**保留字面文本并逐个修复手势。** 如 #5480 所示,每个触及交界节点的手势都需要单独证明,与文本引用之间的优先级规则也仍然存在。
**复用 `ReferenceChipNode`。** 引用 chip 是在提交时经 owner codec 序列化的 occurrence;命令不是模型内容,不能进入 `occurrences`。
**把分隔符放进 chip 文本。** inline decorator 位于块末尾时,Lexical 会在其后追加受管换行,由 `p:last-child::after` 渲染的空参数提示会移到下一行。独立的文本节点让提示留在 chip 所在行。
**用 NodeSelection 选中 chip。** 纯文本绑定的方向键、输入和删除处理器都会忽略非 range 选区,这些手势在 chip 处都需要单独处理。
**在 shell 中用按 NodeKey 索引的表保存认领。** 该表重复了节点已携带的字段,并且每次清空历史时都需要修剪。
## 影响
- Backspace 不再逐字编辑命令名并重新打开菜单。在 chip 边缘按两次 Backspace 或 Delete 会删除 chip,参数保留为普通文字。
- 刷新后,命令目录加载完成即恢复 chip;在此之前以及恢复不适用时,草稿显示字面 token。
- 通过真实按键整体删除 chip 只能在浏览器 lane 中观察,因为 jsdom 缺少 `Selection.modify`;`lifecycle-chrome.e2e.ts` 固定了删除分隔符、选中、删除这三次按键。
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for 2026-10-14-plugin-entry-customization.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 .agents/notes/implemented/architecture/2026-10-14-plugin-entry-customization.md
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus:
en: 2fdf5b95d5603955
zh: bfdc61e7db5f5f5b
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/problem:
en: 896876f139383cba
zh: 42a4aae7e5885780
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision:
en: a6cfc29cb9caa83c
zh: 21bbeb6a7ab20018
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/user-visible-behavior:
en: f1456281aa937626
zh: 6fef46b8d72ddd2b
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/structure-and-ownership:
en: d6a2816ca55b1c38
zh: 53217016f1595755
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/package-changes-relative-to-release-dsh-0-2-1:
en: 18ed81fee1ca7d41
zh: fc298457e54b6488
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/actions-slots-and-menu-content:
en: c87e2fdd911714b5
zh: 074426fdc7abafa9
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/the-three-regions:
en: 770c6150e84742ba
zh: 1d619388c8f38646
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/alternatives-considered:
en: 79355028387c4094
zh: 5b518166e4803ebe
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/verification:
en: 9c6a9ec155bbbfaa
zh: 8bd6e7afc8c43d21
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/consequences:
en: a2459a3316f3ee60
zh: 713b51329995fe46
@@ -0,0 +1,136 @@
# Agent Note: Plugin entry customization: region-owned pins and More menus
Status: implemented
English | [中文](2026-10-14-plugin-entry-customization.zh.md)
## Problem
The `release/dsh-0.2.1` Web app shows plugin entries in three regions: the left sidebar's panel buttons (`sidebar.panellist`), the Conversation header's View tabs (`conversation.view`), and the right sidebar guide's tool cards (guide entries from the right-sidebar tab registry). Each region shows every entry in registration order, so users cannot hide entries they rarely use, and every installed plugin makes the regions more crowded. Users also cannot browse more plugins or start creating a plugin from these positions.
The feature ships in `release/dsh-0.2.1` and is later merged back to master with the release, so it changes as little as possible relative to the release: no changes to the slot framework (`ui-slots`, `ui-renderer`), no new Cordis service, `ui-layout` unchanged, and client plugin packages share only types ([Client package export rules](../../../../packages/client/AGENTS.md)). Persisted data must be readable without migration by a later slot-level store for manual order and pin state.
## Decision
Each region keeps and persists its own list of unpinned entries and filters its own entry buttons with it. Each region draws its own More button, opens a menu with the shared `Menu` component, and renders its actions slot inside that menu. The default-loaded plugin package `ui-entry-customization` registers the same menu-content component into the three actions slots: the entry list with pin buttons, Browse other plugins, and Create your plugin. This follows the Plugins page's Add plugin menu, where the page draws the button and menu and `ui-agent-preset` adds Create plugin through `plugins.add.actions`. Entries cannot be reordered.
### User-visible behavior
- The left sidebar, the Conversation header, and the right sidebar guide each have a More button. Its menu lists the region's customizable entries. Selecting an entry name opens the entry; its pin button shows or hides the entry in the region; the bottom of the menu has Browse other plugins (opens the Plugins main list) and Create your plugin (opens a new or reused blank Session with the Creator preset and fills in the region's prompt without sending it).
- Entries appear in registration order, as in the release; they cannot be reordered.
- Default entries always show and never appear in the menu: Plugins in the left sidebar, Chat in the Conversation header, and Files in the right sidebar.
- Pin state survives page reloads and is shared by every Session.
- Hiding the selected entry does not navigate away from its content; the left-sidebar row or View tab of the selected entry stays visible, as the current item, until another entry is selected.
- The left-sidebar `<nav>` and the Conversation header tab bar keep their release visibility conditions: `<nav>` shows while any panel is registered, and the tab bar shows while more than one View is registered. Both conditions count registered entries, not entries left visible by pin state. With only Chat registered the tab bar stays hidden; with other Views registered, the tab bar and its More button stay visible even when every other View is hidden, so users can pin them again.
- While an actions slot has no registrations, for example when `ui-entry-customization` is not loaded, its region shows no More button.
### Structure and ownership
```text
ui-sidebar / ui-conversation / ui-sidebar-right (each region)
apply: the region's unpinned list (createSnapshotStore + persist) and setPinned
whether the actions slot has registrations (entriesOfSlot + subscribe)
root component: draws only fixed or pinned entries
while the actions slot has registrations, draws the region's More button and Menu:
<Menu anchor={region's More button} …>
renderSlot('<region>.actions', { entries, onOpen, onPinnedChange, onDismiss })
</Menu>
ui-entry-customization (default-loaded)
registers one menu-content component into each actions slot (id 'entry-customization')
content: non-fixed entries with pin buttons, Browse other plugins, Create your plugin; keeps no state
```
Dependency direction: the three regions do not depend on the plugin package and only add uses of `client-store` and `ui-primitives` they already depend on. `ui-entry-customization` depends on the three regions (slot types) and on `ui-plugin-manager` and `ui-workspace` (service types), and calls `ctx.pluginNavigation` and `ctx.uiWorkspace` at runtime. No package gains a new runtime value import from another package.
### Package changes relative to release/dsh-0.2.1
| Package | Change |
|---|---|
| `ui-sidebar` | Adds `sidebar.panellist.actions`; apply adds the unpinned list, `setPinned`, and the actions-slot registration state; `SidebarRoot` filters entries and draws the More button and menu at the end of `<nav>`; expanded panel rows use a 2px gap instead of 4px |
| `ui-conversation` | Adds `conversation.session.header.views.actions`; apply adds the same three parts; the Session header filters View tabs and draws the More button and menu inside the tab bar after the tablist; View tabs use a 32px gap instead of 36px, with 20px before More |
| `ui-sidebar-right` | Adds `sidebar.right.tab.guide.actions`; apply adds the same three parts; the guide filters cards and draws the More card and menu after the cards; the guide registration declares the `sidebarRight` locale namespace |
| `ui-entry-customization` (new package) | Registers the menu-content component into the three actions slots; owns the `entryCustomization` locale namespace; keeps no state |
| `ui-plugin-manager` | Adds `openList()` to `pluginNavigation`: selects the Installed tab of the Plugins main list without opening a dialog or changing the current Session |
| `ui-primitives` | Adds `minWidthFromAnchor` to `Menu` so the right-sidebar menu is never narrower than its card; adds `IconAppsAddOutlineRegular` / `IconAppsAddOutlineMedium` (`compact` for card size), shared by the left-sidebar and right-sidebar buttons |
| `@deepseek-ai/dsh-web-app` | Loads `ui-entry-customization` by default |
### Actions slots and menu content
The three actions slots are root-scoped list slots declared by their regions and rendered inside the region's More menu. Other plugins may register menu items into them; items follow their registration `order`. Each region declares the owner props in its own contract (`SidebarPanelActionsOwnerProps`, `ConversationViewActionsOwnerProps`, `SidebarRightGuideActionsOwnerProps`) with the same fields; the left sidebar's type is:
```ts
interface SidebarPanelActionsOwnerProps {
/** The region's customizable entries in registration order; fixed entries are excluded. */
readonly entries: readonly { readonly id: string; readonly label: string; readonly pinned: boolean }[]
/** Open an entry without changing its pin state. */
readonly onOpen: (id: string) => void
/** Show (true) or hide (false) an entry. */
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
readonly onDismiss: () => void
}
```
The `ui-entry-customization` menu-content component renders those fields: one row per entry (`EntryPickerRow`, whose name and pin button are separately focusable). Selecting the name calls `onDismiss` and then `onOpen`; the pin button calls `onPinnedChange`. Browse other plugins and Create your plugin follow and call `onDismiss` before acting. Each registration receives its region (used only to choose pin copy and the Creator prompt) and the two footer actions through its registration `inject`. Browse other plugins calls `ctx.pluginNavigation.openList()`; Create your plugin calls `ctx.uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt. The call does not go through `ui-agent-preset`, so the preset chip plays no introduction animation. When no Creator preset exists, Session creation fails, Workspace shows its creation-failure notice, and no draft is created.
The region decides its More button's styling, accessible name ("More", "More conversation views", and "More tools", from the regions' `panels.more`, `session.moreViews`, and `tab.guide.more` copy), Tooltip, and the menu's placement and width. Every menu uses the `ui-primitives` `Menu` component.
### The three regions
| | Left sidebar (`ui-sidebar`) | Conversation header (`ui-conversation`) | Right sidebar guide (`ui-sidebar-right`) |
|---|---|---|---|
| Entry source | Existing `panels` (from `sidebar.panellist`) | Existing View list (from `conversation.view`; Trajectory is removed while developer tools are off) | Existing tab registry `guide()` |
| Entry id | Panel id | View id | `kind:entryId` |
| Fixed | `plugins` | `chat` | Entries of kind `files` |
| localStorage key | `dsh.slot.manual-order.sidebar.panellist.v1` | `dsh.slot.manual-order.conversation.view.v1` | `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` |
| Actions slot | `sidebar.panellist.actions` | `conversation.session.header.views.actions` | `sidebar.right.tab.guide.actions` |
| More button | Panel-button styling; shows its label when expanded and a Tooltip when collapsed | Small header button with a Tooltip | Card styling; the menu is at least as wide as the card and aligned to its end |
| `onOpen` | `selectPanel(id)` | The Session's `selectView(id)` | Finds the entry by id, then `openTab(kind, { replaceTab: true })` |
Each region defines its fixed entry in a module constant. The unpinned list is created in apply with client-store's `createSnapshotStore(…, { persist })`, is independent of Sessions, and stores `{ "unpinned": string[] }`. It reaches the region's root component through the inject `hooks.entryPins`; `setPinned` is an inject callback and ignores the fixed entry. Reads accept only an array of strings and treat any other value as an empty list, so corrupted localStorage content cannot break rendering. Ids that are no longer registered stay in `unpinned`, so an entry registered again stays hidden. Whether the actions slot has registrations reaches the root component through `hooks.entryActions`.
Every localStorage key has the form `dsh.slot.manual-order.<list slot key>.v1`, named after the list slot that holds the entries rather than after the region package: the left sidebar and the Conversation header use the existing `sidebar.panellist` and `conversation.view`. The right-sidebar guide entries come from the tab registry and have no list slot in the release, so their key uses the name reserved for one, `sidebar.right.tab.guide.list`. A later slot-level store for the manual order and pin state of a list slot reads these keys and ids directly; saved `unpinned` values need no migration, and fields added later default to empty lists when absent from saved values.
Right-sidebar ids use `kind` instead of `providerId`: when an extension takes over a kind, `providerId` changes while `kind` stays the same, so persisted pin state remains valid after the takeover.
## Alternatives considered
**Keep pin state in a Cordis service (provided by `ui-layout` or the plugin).** A region must read pin state while drawing its own buttons, and a slot only passes data from the region to the plugin, so plugin-held state needs a new public service. A `ui-layout` service would hold state unrelated to layout; a plugin-provided service would require each region to handle the service appearing and disappearing. Region-held state needs only a few lines.
**One pin service per region.** Adds three public services with different signatures, while pin state is used only by its region and that region's menu.
**Add `useSlotEntries` / `useManualOrderedListSlot` to the slot framework.** Requires changing `ui-slots` and `ui-renderer`, makes `ui-renderer` hold user preferences and read and write localStorage, and requires the right sidebar to mirror its guide entries into an internal list slot. Without reordering, each region's pin logic is a few lines, which does not justify changing the framework in a release.
**Drag reordering in the left sidebar.** Only the left sidebar would use it, so the three regions would behave differently; this version does not support reordering.
**Make the popover a Component Factory with the More button as a local slot.** The callers are the three regions and the implementation is the plugin. Declaring the Factory in the plugin package creates a dependency cycle, because the plugin's dependencies `ui-plugin-manager` and `ui-workspace` depend on the regions. Declaring it in `ui-layout` cannot name the plugin's locale namespace, so the declaration would be split across two packages and completed through declaration merging.
**Let the plugin draw the whole More control (button and menu) from region data.** The plugin would copy the three regions' button styles, because CSS Modules cannot be reused across packages, and would need layout state from each region. Button styling carries region-specific behavior and belongs to the region.
**Let the plugin render the region entries.** Left-sidebar icons and right-sidebar cards depend on their regions' child slots, and the plugin cannot reach the region's `renderSlot`; React nodes also cannot be passed as props.
**Stop applying pin state while the plugin is not loaded.** The only benefit is that entries hidden before the plugin was disabled or failed to load would still show. That case requires explicitly disabling a default-loaded plugin; the limitation is accepted and documented in the README.
**Show the tab bar based on entries left visible by pin state.** With other Views registered, hiding all of them would remove the tab bar together with its More button, and users could not pin them again.
**Publish `agentPresetNavigation`, or have `ui-agent-preset` react to `startSession` calls that carry `agentPreset` and play the introduction animation.** The first adds a service only to forward to `startSession`; the second changes the `ui-workspace` and `ui-agent-preset` interfaces, and the introduction animation is not essential to this feature.
**Name localStorage keys after region packages.** A later slot-level store keyed by list slot would have to migrate saved data.
## Verification
- Unit tests for the three regions cover: entry buttons show only fixed or pinned entries; `setPinned` ignores the fixed entry; `setPinned` writes and persists to the region's key; corrupted localStorage content reads as an empty list; ids that are no longer registered are kept; More shows while the actions slot has registrations and passes `entries` without fixed entries and the callbacks, and is absent without registrations; the Conversation header tab bar stays hidden with only Chat and stays visible after every other View is hidden; right-sidebar ids are `kind:entryId`.
- Unit tests for the menu-content component cover: opening an entry, toggling a pin, an empty list showing only the footer actions, the arguments of Browse other plugins and Create your plugin, and removal of the three registrations when the plugin is released.
- The Web e2e `entry-customization.e2e.ts` exercises the left sidebar through the real Web composition: More shows by default, hiding a panel removes its button, the panel still opens from the menu, it stays hidden after a reload, and pinning it again restores the button. Recorded-session Web goldens cover the Conversation header and right-sidebar More buttons; `changed-files-turn.e2e.ts` and `document-preview.e2e.ts` assert that the compact header has no tab bar with only Chat.
- Every changed source file has 100% per-file coverage.
## Consequences
- If a user hides entries and the plugin is later explicitly disabled or fails to load, the hidden entries stay hidden with no menu to restore them; re-enabling the plugin or clearing the region's localStorage key restores them.
- localStorage is isolated by protocol, host, and port, so pin state is not shared after changing the Web port or between Web and Desktop.
- client-store's persist does not validate restored values, so each region validates on read; the validation and the owner props type exist once per region.
- The actions slots sit inside the More menus, so other plugins can add menu items but cannot place arbitrary controls beside the entries.
- The right-sidebar key reserves `sidebar.right.tab.guide.list`; a later list slot for guide entries must use that name for saved pin state to carry over.
- When an extension takes over a kind and uses different guide entry ids, existing pin state does not match the new entries.
- With the plugin loaded by default, Web goldens that record the left navigation, the Conversation header tab bar, or the right sidebar guide include a More button.
@@ -0,0 +1,136 @@
# Agent Note: 插件入口自定义:区域自管的 Pin 与“更多”菜单
Status: implemented
[English](2026-10-14-plugin-entry-customization.md) | 中文
## 问题
`release/dsh-0.2.1` 的 Web 应用在三个区域显示插件入口:左侧栏的面板按钮(`sidebar.panellist`)、会话头部的 View 标签(`conversation.view`)和右侧栏起始页的工具卡片(右侧栏 tab 注册表的 guide 入口)。三处都按注册顺序显示全部入口,用户不能收起不常用的入口,插件越多三处越挤;用户也不能在这些位置直接浏览更多插件或开始创建插件。
该功能随 `release/dsh-0.2.1` 发布,之后随 release 合回 master,因此相对 release 的改动要尽量少:不修改 slot 框架(`ui-slots`、`ui-renderer`),不新增 Cordis 服务,`ui-layout` 保持不变,client 插件包之间只共享类型([Client 包导出规则](../../../../packages/client/AGENTS.md))。持久化的数据要能被以后在 slot 层统一保存手动顺序与 Pin 状态的实现直接沿用。
## 决策
三个区域各自保存本区域被收起入口的列表并持久化,用它过滤自己的入口按钮;各自画自己的“更多”按钮,用共用的 `Menu` 组件弹出菜单,菜单里渲染本区域的 actions slot。默认加载的插件包 `ui-entry-customization` 往三个 actions slot 注册同一个菜单内容组件:入口列表与 Pin 按钮、“浏览更多插件”和“创建你的插件”。做法与插件页的“添加插件”菜单一致:页面画按钮和菜单,`ui-agent-preset` 通过 `plugins.add.actions` 往菜单里加“创建插件”。不提供排序。
### 用户可见行为
- 左侧栏、会话头部、右侧栏起始页各有一个“更多”按钮,打开后的菜单列出该区域可自定义的入口。点入口名称打开它;点 Pin 让它在区域里显示或收起;底部有“浏览更多插件”(打开插件页主列表)和“创建你的插件”(用 Creator 预设新开或复用空白会话,填入该区域对应的提示语,不发送)。
- 入口按注册顺序显示,与 release 一致;不提供排序。
- 默认绑定的入口始终显示,不进入菜单:左侧栏的插件管理、会话头部的 Chat、右侧栏的文件。
- Pin 状态在刷新页面后保留,所有会话共用。
- 收起当前选中的入口不会切走当前内容;左侧栏行或 View 标签在选中其他入口之前保持显示,并标为当前项。
- 左侧栏 `<nav>` 与会话头部标签条的显示条件与 release 一致:`<nav>` 在有已注册面板时显示,标签条在注册的 View 多于一个时显示。条件按已注册的入口计算,不按 Pin 后可见的入口计算,因此只有 Chat 时不显示标签条;有其他 View 时即使全部收起,标签条和其中的“更多”仍然显示,用户可以重新固定。
- actions slot 没有注册项时(例如 `ui-entry-customization` 未加载),区域不显示“更多”。
### 结构与职责
```text
ui-sidebar / ui-conversation / ui-sidebar-right (each region)
apply: the region's unpinned list (createSnapshotStore + persist) and setPinned
whether the actions slot has registrations (entriesOfSlot + subscribe)
root component: draws only fixed or pinned entries
while the actions slot has registrations, draws the region's More button and Menu:
<Menu anchor={region's More button} …>
renderSlot('<region>.actions', { entries, onOpen, onPinnedChange, onDismiss })
</Menu>
ui-entry-customization (default-loaded)
registers one menu-content component into each actions slot (id 'entry-customization')
content: non-fixed entries with pin buttons, Browse other plugins, Create your plugin; keeps no state
```
依赖方向:三个区域不依赖插件包,只增加 `client-store` 与 `ui-primitives` 的既有用法;`ui-entry-customization` 依赖三个区域(slot 类型)、`ui-plugin-manager` 与 `ui-workspace`(服务类型),运行时通过 `ctx.pluginNavigation` 与 `ctx.uiWorkspace` 调用。各包之间不新增运行时值导入。
### 各包改动(相对 release/dsh-0.2.1)
| 包 | 改动 |
|---|---|
| `ui-sidebar` | 新增 `sidebar.panellist.actions`;apply 中新增 unpinned 列表、`setPinned` 和 actions slot 注册状态;`SidebarRoot` 过滤入口,并在 `<nav>` 末尾画“更多”按钮与菜单;展开态面板行间距由 4px 改为 2px |
| `ui-conversation` | 新增 `conversation.session.header.views.actions`;apply 中新增同样三项;会话头部过滤 View 标签,并在标签条内、tablist 之后画“更多”按钮与菜单;View 标签间距由 36px 改为 32px,与“更多”间隔 20px |
| `ui-sidebar-right` | 新增 `sidebar.right.tab.guide.actions`;apply 中新增同样三项;起始页过滤卡片,并在卡片之后画“更多”卡片与菜单;guide 注册声明 `sidebarRight` 文案命名空间 |
| `ui-entry-customization`(新包) | 往三个 actions slot 注册菜单内容组件;`entryCustomization` 文案命名空间;不保存状态 |
| `ui-plugin-manager` | `pluginNavigation` 增加 `openList()`:切到插件页主列表的“已安装”标签,不打开对话框,不改变当前会话 |
| `ui-primitives` | `Menu` 增加 `minWidthFromAnchor`,让右侧栏菜单不窄于卡片;新增 `IconAppsAddOutlineRegular` / `IconAppsAddOutlineMedium`(`compact` 用于卡片尺寸),供左侧栏和右侧栏的按钮共用 |
| `@deepseek-ai/dsh-web-app` | 默认加载 `ui-entry-customization` |
### actions slot 与菜单内容
三个 actions slot 都是 root 级 list slot,由各区域声明,渲染在本区域的“更多”菜单里,其他插件也可以往里注册菜单项,排列顺序按注册项的 `order`。owner props 由各区域在自己的 contract 中声明(`SidebarPanelActionsOwnerProps`、`ConversationViewActionsOwnerProps`、`SidebarRightGuideActionsOwnerProps`),三处字段相同,以左侧栏为例:
```ts
interface SidebarPanelActionsOwnerProps {
/** The region's customizable entries in registration order; fixed entries are excluded. */
readonly entries: readonly { readonly id: string; readonly label: string; readonly pinned: boolean }[]
/** Open an entry without changing its pin state. */
readonly onOpen: (id: string) => void
/** Show (true) or hide (false) an entry. */
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
readonly onDismiss: () => void
}
```
`ui-entry-customization` 的菜单内容组件按上述字段渲染:每个入口一行(`EntryPickerRow`,名称与 Pin 按钮各自可聚焦),点名称先 `onDismiss` 再 `onOpen`,点 Pin 调 `onPinnedChange`;其后是“浏览更多插件”和“创建你的插件”,先 `onDismiss` 再执行。每个注册项通过 registration `inject` 得到所属区域(只用于选择 Pin 提示与创建提示语)和两个底部操作:“浏览更多插件”调用 `ctx.pluginNavigation.openList()`;“创建你的插件”调用 `ctx.uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`,`prompt` 按区域取本地化文案。该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画;Creator 预设不存在时会话创建失败,Workspace 显示创建失败提示,不产生草稿。
“更多”按钮的样式、无障碍名称(“更多”“更多会话视图”“更多工具”,分别来自各区域的 `panels.more`、`session.moreViews`、`tab.guide.more` 文案)、Tooltip、菜单的弹出方向与宽度都由区域决定;菜单外框统一使用 `ui-primitives` 的 `Menu`。
### 三个区域
| | 左侧栏(`ui-sidebar`) | 会话头部(`ui-conversation`) | 右侧栏起始页(`ui-sidebar-right`) |
|---|---|---|---|
| 入口来源 | 现有的 `panels`(来自 `sidebar.panellist`) | 现有的 View 列表(来自 `conversation.view`,未开启开发者工具时去掉 Trajectory) | 现有的 tab 注册表 `guide()` |
| 入口 id | 面板 id | View id | `kind:入口id` |
| fixed | `plugins` | `chat` | kind 为 `files` 的入口 |
| localStorage 键 | `dsh.slot.manual-order.sidebar.panellist.v1` | `dsh.slot.manual-order.conversation.view.v1` | `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` |
| actions slot | `sidebar.panellist.actions` | `conversation.session.header.views.actions` | `sidebar.right.tab.guide.actions` |
| “更多”按钮 | 与面板按钮同样式;展开时显示文字,收起时显示 Tooltip | 头部样式的小按钮,带 Tooltip | 卡片样式,菜单不窄于卡片并右对齐 |
| `onOpen` | `selectPanel(id)` | 本会话的 `selectView(id)` | 按 id 找到入口后 `openTab(kind, { replaceTab: true })` |
每个区域用一个模块常量定义 fixed。unpinned 列表在 apply 中用 client-store 的 `createSnapshotStore(…, { persist })` 创建,与会话无关,值为 `{ "unpinned": string[] }`,经 inject 的 `hooks.entryPins` 交给区域根组件,`setPinned` 作为 inject 回调,对 fixed 入口不生效。读取时只接受字符串数组,其他值按空列表处理,避免损坏的 localStorage 内容影响渲染。`unpinned` 中已不存在的 id 保留,同一入口重新注册后保持收起。actions slot 是否有注册项经 `hooks.entryActions` 交给根组件。
localStorage 键统一为 `dsh.slot.manual-order.<list slot key>.v1`,按入口所在的 list slot 命名,不按区域包命名:左侧栏与会话头部使用现有的 `sidebar.panellist`、`conversation.view`;右侧栏的 guide 入口来自 tab 注册表,release 中还没有对应的 list slot,键名使用为它预留的 `sidebar.right.tab.guide.list`。以后在 slot 层统一保存同一 list slot 的手动顺序与 Pin 状态时,直接沿用这些键和 id,已保存的 `unpinned` 不需要迁移;新增的字段在旧值中缺失时按空列表处理。
右侧栏 id 使用 `kind` 而不是 `providerId`:扩展接管某个 kind 时 `providerId` 会改变,`kind` 不变,持久化的 Pin 状态在接管后仍然有效。
## 考虑过的替代方案
**由一个 Cordis 服务保存 Pin 状态(在 `ui-layout` 或插件中提供)。** 区域画自己的按钮时要读到 Pin 状态,而 slot 只能把数据从区域传给插件;由插件保存就必须新增公开服务。放在 `ui-layout` 会让布局服务承担与布局无关的状态;由插件提供时,区域还要处理服务时有时无。区域自己保存只需几行。
**三个区域各提供一个 Pin 服务。** 新增三个形态不同的公开服务,而 Pin 状态只被所在区域和它的菜单使用。
**在 slot 框架中提供 `useSlotEntries` / `useManualOrderedListSlot`。** 需要修改 `ui-slots` 与 `ui-renderer`,让 `ui-renderer` 持有用户偏好并读写 localStorage,右侧栏还要在内部同步成 list slot;不提供排序时,各区域的 Pin 逻辑只有几行,不值得为 release 修改框架。
**左侧栏拖动排序。** 只有左侧栏使用,三个区域的交互不一致;本版本不提供排序。
**把弹出框做成 Component Factory,“更多”按钮作为局部 slot。** 调用方是三个区域,实现方是插件:Factory 声明放在插件包会形成依赖环(插件依赖的 `ui-plugin-manager`、`ui-workspace` 依赖区域),放在 `ui-layout` 又引用不到插件的文案命名空间,只能把声明拆在两个包里用声明合并补齐。
**插件画整个“更多”(按钮与菜单),区域只提供数据。** 插件要复制三个区域的按钮样式(CSS Module 不能跨包复用),并从区域取得布局状态;按钮样式带有区域的业务特点,应留在各自的区域。
**由插件渲染区域入口。** 左侧栏图标和右侧栏卡片依赖各自的子 slot,插件拿不到区域的 `renderSlot`;ReactNode 也不能作为 props 传递。
**插件未加载时停止应用 Pin 状态。** 唯一收益是插件在用户收起入口后被禁用或加载失败时,入口仍能显示;该情形需要显式禁用默认加载的插件,接受这一限制并在 README 中说明。
**按 Pin 后可见的入口数决定标签条是否显示。** 有其他 View 时全部收起后,标签条和其中的“更多”一起消失,用户无法重新固定。
**公开 `agentPresetNavigation`,或让 `ui-agent-preset` 响应带 `agentPreset` 的 `startSession` 并播放介绍动画。** 前者只为转调 `startSession` 新增服务;后者要修改 `ui-workspace` 与 `ui-agent-preset` 的接口,介绍动画不是本功能的必要部分。
**按区域包命名 localStorage 键。** 以后在 slot 层按 list slot 统一保存时需要迁移已保存的数据。
## 验证
- 三个区域的单元测试覆盖:入口按钮只显示 fixed 或未收起的入口;对 fixed 入口调用 `setPinned` 不生效;`setPinned` 写入并持久化到对应键;localStorage 内容损坏时按空列表处理;已不存在的 id 被保留;actions slot 有注册项时显示“更多”并传入不含 fixed 入口的 `entries` 与回调,没有注册项时不显示;会话头部标签条在只有 Chat 时不显示,有其他 View 时全部收起后仍然显示;右侧栏 id 为 `kind:入口id`。
- 菜单内容组件的单元测试覆盖:入口打开、Pin 切换、空列表时只有底部操作、浏览更多插件与创建插件的调用参数,以及三个注册项在插件释放后移除。
- Web e2e `entry-customization.e2e.ts` 通过真实 Web 组合验证左侧栏:默认显示“更多”,收起后面板按钮消失,仍可从菜单打开,刷新后保持收起,重新固定后恢复。会话头部与右侧栏的“更多”由录制会话的 Web golden 覆盖;`changed-files-turn.e2e.ts` 与 `document-preview.e2e.ts` 断言只有 Chat 时紧凑头部没有标签条。
- 改动的源文件逐文件覆盖率 100%。
## 后果
- 用户收起入口后,若插件被显式禁用或加载失败,被收起的入口保持隐藏且没有菜单可以恢复;恢复方式是重新启用插件或清除对应 localStorage 键。
- localStorage 按协议、主机和端口隔离,更换 Web 端口或在 Web 与 Desktop 之间切换时 Pin 状态不共享。
- client-store 的 persist 读回时不校验格式,三个区域各自在读取处校验;校验逻辑与 owner props 类型在三处各有一份。
- actions slot 位于“更多”菜单内,其他插件只能往里加菜单项,不能在入口旁放置任意控件。
- 右侧栏的键名预留了 `sidebar.right.tab.guide.list`;以后为 guide 入口新增 list slot 时必须使用这个名称,已保存的 Pin 状态才能沿用。
- 扩展接管某个 kind 且使用不同的 guide 入口 id 时,原有 Pin 状态无法对应到新入口。
- 插件默认加载后,录有左侧导航、会话头部标签条或右侧栏起始页的 Web golden 都会出现“更多”按钮。
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for 2026-10-14-webworker-preview-profile-parity.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 .agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand:
en: f43aec03d4eeb831
zh: fb40c3fd679876f3
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/problem:
en: 0dd8324644e69400
zh: 19d12f096078d72c
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision:
en: a15c7c13f167e5ac
zh: 63bcc2ef60c2f344
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/boot-the-same-profile-as-the-served-web-deployment:
en: caf1ca77192dcb20
zh: d663e2cbb05fe4fc
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/node-internal-module-interfaces-in-the-worker:
en: 37d8e809eaa21d25
zh: 8c7c31bd6f45c823
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/inspector-degradation-on-platforms-without-worker-threads:
en: 608b24ca97ab5bca
zh: 94bb28377c856b2e
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/separate-archives-for-lazily-loaded-chunks:
en: 428699f74f19e998
zh: 2e8fcdbc9616f70b
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/watching-and-client-plugin-delivery:
en: f3e6ba098433a70a
zh: 345b93f451d2768c
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/alternatives-considered:
en: 99ee505e82fa9c7c
zh: 448bc0e160424cbf
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/consequences:
en: 53d72faa09e78410
zh: a5224e4662a4846b
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/testing:
en: 532918df1acd147d
zh: e476c090d183d0a4
@@ -0,0 +1,105 @@
# Agent Note: Boot the Preview Worker from the shipped profile and fetch lazy chunks on demand
Status: implemented
English | [中文](2026-10-14-webworker-preview-profile-parity.zh.md)
## Problem
The browser Preview ([webworker-runtime](../../../../packages/experimental/webworker-runtime/README.md), [webworker-packer](../../../../packages/experimental/webworker-packer/README.md)) can receive its boot configuration at pack time: the packer runs `dsh --profile web --dump-config` against a temporary home and writes the output to the image's `config/cordis.yml`; the Worker then inserts the whole configuration back as the profile's own patch (one `insert`). Measured on such an image, this has three consequences:
| Symptom | Cause |
|---|---|
| The Plugins page shows no optional bundle | The image only collects rows that appear in the dump, so none of the 11 default-off `OPTIONAL_BUNDLES` is included; the Worker's `installAnchor` points at the profile's own package.json, which declares no installation dependencies |
| Even when added, some bundles do not take effect after they are enabled | Bundle layers apply before the profile patch, when the base rows do not exist yet: the session-titles disable patch is skipped, agent-team produces 4 `entry not found` warnings, and rows a bundle inserts move to the front |
| The image is 12.79 MiB | 4 lazily loaded chunks (documentpreview's pdf, excel, and frontmatter chunks and sidebar-terminal's terminal chunk, about 4.4 MiB together) download together with the code needed at startup |
Measured: with no bundle enabled, the two compositions differ in only one of 188 rows, the `compression: none` setting of `session-persistence-jsonl`. The differences come from how the configuration is layered, not from the data itself.
## Decision
### Boot the same profile as the served Web deployment
The served launch path (apps/cli, app-boot) carries no Preview-specific code; the webworker packages reproduce it:
- **packer**
- Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from app-boot, the same source the Plugins page uses.
- The image carries a manifest-only `node_modules/@deepseek-ai/dsh/package.json` (a verbatim copy of `apps/cli/package.json`) as the installation anchor, and the image manifest names it in `installation`.
- The image carries no composed configuration and no `config/cordis.yml`; the image manifest records the `profile` the Worker boots.
- The image carries a home patch, `home/cordis.patch.yml`: for each row the runtime's `PLATFORM_ENTRY_CONFIG` names (`session-persistence-jsonl`), the config the template's bundle layers compose with the required fields over it (`compression: none`). A patch replaces a row's whole config, so the composed fields stay; the bundle files read at pack time are the ones in the image.
- **Worker**
- The image's app-boot `loadProfile` initializes `home/profiles/<profile>` from the template, the Worker writes an empty root `cordis.yml`, and `readProfilePatches` composes the layers.
- profileContext is filled the way `runProfile` fills it: name, dir, patchPath, installAnchor, startedBundles, cwd, home, and an empty overlay list, so the Worker calls `readProfilePatches` once, as the launcher does without `--patch`.
- The Worker provides a launchEnvironment snapshot and mounts `PluginPackages` without a resolution.
- **Retained platform differences**: jsonl `compression: none` through the image's home patch (ConfigEditor rejects edits to that row's config, as it rejects any row a home patch overrides), the module loader (the Worker's own CommonJS loader, provided through the Node internal-module interfaces in the next section), log output, command-line arguments, appReady timing, no pnpm, and no process-level proxy or signal handling.
The Worker uses existing app-boot exports; app-boot has no Preview override or injection point.
### Node internal-module interfaces in the Worker
Plugin metadata reads (`readPluginMeta`), configuration schema dumps, and HMR do not go through ctx: they take Node internal modules directly through `ModuleLoader.fromInternal()` or `node-addon-require-builtin.requireBuiltin(id)`. A Worker that hides those modules, for example by reporting `process.versions.node` as `0.0.0` so that `fromInternal()` returns undefined, breaks these callers once `PluginPackages` is mounted: every bundle on the Plugins page reports "Plugin metadata requires the Node module resolver", and HMR, which finds no `loadCache`, returns `restart-required` for every switch.
- **Interfaces only**: the Worker replaces `node-addon-require-builtin` with a static module whose `requireBuiltin(id)` answers the 5 modules callers read, under Node v24.18.0 ids, with Node's member names and signatures; every other id throws `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`. `process.versions.node` reports `24.18.0`, and `execArgv` carries no `--expose-internals`.
- **`internal/modules/esm/loader`**: the cascaded loader's `resolveSync(parentURL, request)` and `import` delegate to the Worker's existing loader, and failed resolution carries Node's `ERR_MODULE_NOT_FOUND` or `ERR_PACKAGE_PATH_NOT_EXPORTED`; `loadCache` is an empty `LoadCache`; `getOrCreateModuleJob` exists and refuses when called. The Worker loader instance owns the cascaded loader.
- **Members HMR replaces**: `Module` from `internal/modules/cjs/loader`, `package_json_reader`, the `modules` binding from `internal/bootstrap/realm`, and `ResolveCache` from `module_map` keep Node's member names so HMR can replace and restore them; they refuse when called. The Worker's own `require` does not route through them.
- **One cascaded loader**: the Worker does not override `ctx.loader.internal`; the Cordis Loader and static callers reach one cascaded loader through `fromInternal()`.
- **Node behavior for measured callers**: `node:path.win32.isAbsolute` (icon path validation) and `node:util.inherits` (`http-errors`), both following Node's source.
### Inspector degradation on platforms without worker_threads
The Worker's structural stubs throw errors carrying `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`. The Inspector Host degrades only when its Worker fails to start with that code: it warns once, the bootstrap route answers 503, and it registers no DevTools route, injects nothing, and provides no `inspector` service; every other startup failure fails the fiber. `serve-static` and `open` load after the Worker starts, so a degraded start never loads them. Session Inspector in the same bundle therefore runs.
### Separate archives for lazily loaded chunks
- **Classification**: the client build preset emits package-local chunk requests only as `require.async("./client.<name>.js")`, and `verify-client-packages` forbids synchronous chunk requires. The packer's `lazyChunksOf` scans every page asset in the swept image (each package's `lib/client.js` and `lib/client.*.js`) for these literals, so a chunk that another chunk requests is found without following requests from the `./client` entry. A target that a package's `./client` export names under any condition (such as `client.browser.js`) loads eagerly and stays in the base image; every other target leaves it. A request for a chunk the package does not publish fails the pack.
- **Archives**: each chunk gets one deterministic tar.gz named `lazy/<sha256>.tar.gz` after the SHA-256 of the compressed archive. The base manifest's `lazy` list records each archive as `{ archive, files: [{ path, size }] }`, with no file mode. There is no size threshold.
- **Registration**: the Worker registers each listed file with `MemoryVfs.deferArchive`, which creates its parent directories and gives it the VFS default file mode; an image that lists lazy archives must be fetched from a URL, against which the archive names resolve. `exists`, `stat`, and `readdir` answer synchronously from the registration.
- **Fetch**: the asynchronous faces of the Worker's `node:fs` and `node:fs/promises` that read a registered file wait for its archive: `fs.promises.readFile`, `fs.promises.open` (so every FileHandle opens after arrival), `fs.promises.cp`, and `fs.createReadStream`. The first of them fetches the archive, inflates it, checks that it carries exactly the registered paths at the registered sizes, and materializes the files; concurrent readers share one fetch. The digest in the archive name is not verified. A failed fetch fails only the reads waiting on it; the files stay registered and the next asynchronous read fetches again.
- **Before arrival**: synchronous reads, and writes, appends, truncation, chmod, renames, and links in their synchronous and promise forms, are refused with `ERR_DSH_VFS_DEFERRED`, naming the archive; a directory rename that would move a registered file is refused before the destination changes, so the destination stays as it was. Unlink and recursive removal drop the registration, and seeding the path replaces it; an archive that arrives afterwards leaves those paths as they are.
- **client-modules**: `chunkResponse` reads a chunk with `fs.promises.readFile` inside `lazyBody`, which shares one read among concurrent requests and drops a rejected read once it settles, so the next request reads again. Under Node the asynchronous read returns the same bytes as a synchronous one.
### Watching and Client plugin delivery
- Worker `fs.watch` and `fs.watchFile` listeners run in the async context where the watcher was created, as Node delivers watch events from the watch handle, not in the context of the code whose write produced the event. HMR's configuration watcher therefore runs outside the writer's HMR transaction, and a configuration write made inside a transaction does not make the follow-up reload fail as a nested transaction.
- The browser half of `@deepseek-ai/dsh-client-hmr` reads `/plugins/events` through the page's `__DSH_TRANSPORT__.fetch` when a shell provides one (the Worker preview tunnel), otherwise through `EventSource`. The Client plugins of a bundle enabled in the Preview therefore mount without a page reload.
## Alternatives considered
**Keep the pack-time composition and wrap it in a synthetic base bundle inside the Worker.** The change is smaller, but the synthetic bundle appears in the plugin list, the self-composed pack-time configuration remains, and the Preview still differs from the served Web deployment.
**Fetch chunks with synchronous XHR in the Worker.** client-modules stays unchanged, but the whole Host tree blocks while the download runs.
**Prefetch in the tunnel by recognizing chunk request URLs.** The tunnel would need a copy of client-modules' `/plugins/<id>/<file>` routing rules.
**Combine archives per package or above a size threshold.** Each `require.async` needs exactly one file, so one archive per chunk fits best; a threshold would be a tunable with no supporting evidence.
**Port Node v24's CommonJS loader and resolver into the Worker and replace the Worker's whole `require` pipeline.** Resolution would match Node exactly, but it requires Node's condition set (changing image contents), cjs-module-lexer for named exports, and about 300 KB more in worker.js, while callers need only the internal-module interfaces.
**Inject a resolver into `readPluginMeta`.** It changes an app-boot public function to accommodate a platform difference, and other `fromInternal()` callers such as the configuration schema dump still get no loader.
**Emulate `--expose-internals`.** Without that flag, the vendored Loader, HMR, and app-boot all fall back to `node-addon-require-builtin`, so replacing that package covers every caller.
## Consequences
The Preview boots the composition the served Web deployment boots. With no bundle enabled, the Worker composition differs from the served Web composition only by the jsonl platform override, and enabling any bundle skips no patch. The Plugins page and Settings → Built-in plugins list every `OPTIONAL_BUNDLES` entry with its title and icon and no metadata error. Switching each of the 11 optional bundles on and then off returns `applied` both times; after enabling, every enabled row it changes is active, and after disabling, the plugin list returns to its prior rows. Enabling session-titles disables `@deepseek-ai/dsh-session-title-first-prompt-llm` (row `session-title-llm`) and enables `@deepseek-ai/dsh-session-title-all-prompts-llm`, as in the served Web deployment; enabling inspector-profile runs Session Inspector and starts the NodeJS Inspector degraded. Degradation requires `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, so a served `dsh web` runs the full Inspector.
Size (MiB, measured): the base image is 9.20 (12.79 with pack-time composition and eager chunks, −28%) including 11 optional bundles, plus 5 lazy tar archives totaling 4.45 (pdf 2.88, excel 1.39, terminal 0.13, frontmatter 0.05, Inspector bottom panel 0.00). The base image contains no lazily loaded chunk, and the page fetches an archive only when it requests the chunk.
The trade-off costs:
- HMR replaces no modules: the Worker's ES module cache stays empty, so a module file changed in the VFS keeps running its loaded code while HMR reports `applied`. The Preview image changes no module files (package operations fail with the `execa` refusal).
- The Node internal-module members that exist only by name refuse when called; a future caller that invokes them fails explicitly in the Preview.
- Reporting `process.versions.node` as `24.18.0` can change third-party code that branches on the version; a scan of the image found only undici selecting wasm SIMD by version on ppc64, which does not apply to the Worker.
- CommonJS `require('stream')` returns the module namespace rather than the `Stream` constructor, so code that passes it to `util.inherits` fails at load.
- Capabilities in optional bundles that cannot run in a browser (sqlite, terminals, local speech recognition) fail when used: enabling them reports no error, and using them does. A degraded NodeJS Inspector keeps only its bootstrap route, which answers 503.
- The image layout carries no `config/cordis.yml`, `WorkerHostOptions` has no `configPath`, and an image manifest without `profile` fails validation, so an image packed with a composed configuration must be repacked.
- Deferred files require readers to use the waiting asynchronous faces; a new synchronous reader fails explicitly in the Preview instead of degrading. The Worker checks only the registered paths and sizes of an arrived archive, not the digest that names it.
## Testing
- [profile-image.spec.ts](../../../../packages/experimental/webworker-packer/tests/profile-image.spec.ts) pins the roster (template bundles, every optional bundle, and the packages their patch rows name) and an image that records the profile and installation and carries only the installation manifest.
- [lazy-chunks.spec.ts](../../../../packages/experimental/webworker-packer/tests/lazy-chunks.spec.ts) pins one archive per `require.async` chunk, identical archive names and bytes across packs, refusal of an unpublished chunk, and `./client` export targets kept in the base image.
- [lazy-archive.spec.ts](../../../../packages/experimental/webworker-runtime/tests/storage/lazy-archive.spec.ts) pins registration answers, `ERR_DSH_VFS_DEFERRED` refusals, the unchanged rename destination, removal and seeding, one shared fetch, retry after a failed fetch, and the waiting `node:fs` faces.
- [require-builtin.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/require-builtin.spec.ts) pins the provided internal modules, refusal of other ids with the platform code, the cascaded loader, the members HMR replaces, and the reported Node version; [fs-watch-context.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/fs-watch-context.spec.ts) pins watcher listeners running in the watcher's creation context.
- [bootstrap-route.host.spec.ts](../../../../packages/experimental/inspector/tests/bootstrap-route.host.spec.ts) pins Inspector degradation; [transport.client.spec.ts](../../../../packages/client/hmr/tests/transport.client.spec.ts) pins the page-transport event stream; [node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) pins the chunk read retry.
- [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) boots the packed Worker deployment, requests a lazy chunk through the tunnel, switches every optional bundle on and off, checks the session-titles and inspector-profile rows, and mounts an enabled bundle's Client plugin without a reload. The image sizes are measured, not pinned by a test.
@@ -0,0 +1,105 @@
# Agent Note: Preview Worker 按正式 profile 启动,懒加载 chunk 按需拉取
Status: implemented
[English](2026-10-14-webworker-preview-profile-parity.md) | 中文
## 问题
浏览器 Preview([webworker-runtime](../../../../packages/experimental/webworker-runtime/README.zh.md)、[webworker-packer](../../../../packages/experimental/webworker-packer/README.zh.md))的启动配置可以在打包时生成:packer 用临时 home 运行 `dsh --profile web --dump-config`,把输出写进镜像的 `config/cordis.yml`;Worker 再把整份配置当作 profile 自己的补丁(一条 `insert`)插回去。在这样的镜像上实测,会带来三个后果:
| 现象 | 原因 |
|---|---|
| 插件页看不到任何 optional bundle | 镜像只收 dump 里出现的行,11 个默认关闭的 `OPTIONAL_BUNDLES` 都不在里面;Worker 的 `installAnchor` 指向 profile 自己的 package.json,没有安装依赖 |
| 即使补上,启用后有的 bundle 也不生效 | bundle 层排在 profile 补丁前面,基础行这时还不存在:session-titles 的禁用补丁被跳过,agent-team 产生 4 条 `entry not found`,bundle 新增的行排到了最前面 |
| 镜像 12.79 MiB | 4 个懒加载 chunk(documentpreview 的 pdf、excel、frontmatter,sidebar-terminal 的 terminal,共约 4.4 MiB)和启动必需的代码一起下载 |
实测:不开 bundle 时,两边组合的 188 行里只有 `session-persistence-jsonl` 的 `compression: none` 一处不同。差异来自配置的分层方式,不在数据本身。
## 决策
### 与正式 Web 相同的 profile 启动
正式启动链路(apps/cli、app-boot)不含 Preview 专用代码;由 webworker 包复现这条链路:
- **packer**
- 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 app-boot 读,与插件页同一来源。
- 镜像里放一份只含清单的 `node_modules/@deepseek-ai/dsh/package.json`(原样复制 `apps/cli/package.json`),作为安装锚点,镜像 manifest 在 `installation` 里记录它。
- 镜像不带组合好的配置,也没有 `config/cordis.yml`;镜像 manifest 记录 Worker 要启动的 `profile`。
- 镜像带一份 home patch `home/cordis.patch.yml`:对运行时 `PLATFORM_ENTRY_CONFIG` 列出的每一行(`session-persistence-jsonl`),写入模板各 bundle 层合成出的配置,并在其上设置所需字段(`compression: none`)。补丁会整体替换一行的配置,所以保留合成出的字段;打包时读取的 bundle 文件就是镜像里的那些。
- **Worker**
- 用镜像里 app-boot 的 `loadProfile` 从模板初始化 `home/profiles/<profile>`,写空的根 `cordis.yml`,用 `readProfilePatches` 组合。
- profileContext 按 `runProfile` 填写:name、dir、patchPath、installAnchor、startedBundles、cwd、home,overlay 列表为空,因此 Worker 与不带 `--patch` 的启动器一样只调用一次 `readProfilePatches`。
- 提供 launchEnvironment 快照,挂上不带 resolution 的 `PluginPackages`。
- **保留的平台差异**:jsonl `compression: none`(经镜像的 home patch 提供;ConfigEditor 会拒绝修改这一行的配置,与拒绝任何被 home patch 覆盖的行一致)、模块加载器(Worker 自己的 CommonJS 加载器,经 Node internal 模块接口提供,见下节)、日志输出、启动参数、appReady 时机、没有 pnpm、没有进程级代理和信号处理。
Worker 只用 app-boot 现有导出;app-boot 没有 Preview 专用的 override 或注入点。
### Worker 提供 Node internal 模块接口
读插件元信息(`readPluginMeta`)、配置 schema 导出和 HMR 不经过 ctx,而是用 `ModuleLoader.fromInternal()` 或 `node-addon-require-builtin.requireBuiltin(id)` 直接取 Node 的 internal 模块。如果 Worker 藏起这些模块,例如把 `process.versions.node` 报成 `0.0.0` 让 `fromInternal()` 返回空,挂上 `PluginPackages` 后这些调用方就会出错:插件页每个 bundle 都报“Plugin metadata requires the Node module resolver”,HMR 看不到 `loadCache`,每次开关都返回 `restart-required`。
- **只照搬接口**:Worker 用静态模块替换 `node-addon-require-builtin`,`requireBuiltin(id)` 按 Node v24.18.0 的 id 返回调用方实际读取的 5 个模块,成员名和签名与 Node 一致;其余 id 抛 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`。`process.versions.node` 报 `24.18.0`,`execArgv` 不带 `--expose-internals`。
- **`internal/modules/esm/loader`**:cascaded loader 的 `resolveSync(parentURL, request)` 和 `import` 交给 Worker 现有的加载器,解析失败带 Node 的 `ERR_MODULE_NOT_FOUND`、`ERR_PACKAGE_PATH_NOT_EXPORTED`;`loadCache` 是空的 `LoadCache`;`getOrCreateModuleJob` 存在但调用即拒绝。cascaded loader 由 Worker 加载器实例持有。
- **HMR 替换的成员**:`internal/modules/cjs/loader` 的 `Module`、`package_json_reader`、`internal/bootstrap/realm` 的 `modules` binding、`module_map` 的 `ResolveCache` 保留 Node 的成员名,供 HMR 替换与还原;被调用时拒绝。Worker 自己的 `require` 不经过它们。
- **同一个 cascaded loader**:Worker 不覆写 `ctx.loader.internal`;Cordis Loader 与静态调用方经 `fromInternal()` 拿到同一个 cascaded loader。
- **按实测调用方补齐的 Node 行为**:`node:path.win32.isAbsolute`(图标路径校验)、`node:util.inherits`(`http-errors`),均按 Node 源码实现。
### Inspector 在没有 worker_threads 的平台上降级
Worker 的结构化 stub 抛出带 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 的错误。Inspector Host 只在 Worker 启动以这个错误码失败时降级:警告一次,bootstrap 路由返回 503,不注册 DevTools 路由、不注入、不提供 `inspector` 服务;其他启动错误让 fiber 失败。`serve-static` 和 `open` 在 Worker 启动成功后才加载,降级时不加载。同一 bundle 里的 Session Inspector 因此正常运行。
### 懒加载 chunk 独立归档
- **分类**:client 构建预设只以 `require.async("./client.<name>.js")` 的形式产出包内 chunk 请求,`verify-client-packages` 禁止同步 require chunk。packer 的 `lazyChunksOf` 扫描清扫后镜像里的每个页面资源(各包的 `lib/client.js` 和 `lib/client.*.js`)中的这些字面量,因此 chunk 引用的 chunk 不必从 `./client` 入口沿请求追踪也能找到。在任一条件下被某个包的 `./client` 导出指向的目标(如 `client.browser.js`)会被立即加载,留在基础镜像;其余目标移出基础镜像。请求包未发布的 chunk 会让打包失败。
- **归档**:每个 chunk 一个确定性 tar.gz,按压缩后归档的 SHA-256 命名为 `lazy/<sha256>.tar.gz`。基础 manifest 的 `lazy` 列表把每个 archive 记录为 `{ archive, files: [{ path, size }] }`,不记录文件 mode。不设大小阈值。
- **登记**:Worker 用 `MemoryVfs.deferArchive` 登记列出的每个文件,同时创建其父目录,并赋予 VFS 默认文件 mode;列出懒加载 archive 的镜像必须从 URL 拉取,archive 名相对这个 URL 解析。`exists`、`stat`、`readdir` 依据登记同步作答。
- **拉取**:Worker 的 `node:fs` 和 `node:fs/promises` 中读取已登记文件的异步接口会等待其 archive:`fs.promises.readFile`、`fs.promises.open`(因此每个 FileHandle 都在到达后才打开)、`fs.promises.cp` 和 `fs.createReadStream`。其中第一个调用拉取 archive、解压,校验其中恰好是登记的路径和登记的大小,再物化文件;并发读取方共用一次拉取。archive 名里的摘要不做校验。拉取失败只让等待它的读取失败;文件保持登记,下一次异步读取再次拉取。
- **到达之前**:同步读取,以及同步和 promise 形式的写入、追加、截断、chmod、重命名和硬链接,都以 `ERR_DSH_VFS_DEFERRED` 拒绝,并给出 archive 名;会移动已登记文件的目录重命名在目标改变之前就被拒绝,目标保持原样。unlink 和递归删除会撤销登记,对该路径 seed 会替换登记;之后到达的 archive 不改动这些路径。
- **client-modules**:`chunkResponse` 在 `lazyBody` 里用 `fs.promises.readFile` 读取 chunk;`lazyBody` 让并发请求共用一次读取,被拒绝的读取一旦落定就丢弃,下一次请求重新读取。在 Node 下,异步读取与同步读取得到相同字节。
### 文件监听与 Client 插件下发
- Worker 的 `fs.watch` 和 `fs.watchFile` 监听器在创建 watcher 时的异步上下文里运行,与 Node 从 watch 句柄派发事件一致,而不是在写入方的上下文里运行。因此 HMR 的配置 watcher 在写入方的 HMR 事务之外运行,事务内的配置写入不会让随后的重载因嵌套事务而失败。
- `@deepseek-ai/dsh-client-hmr` 的浏览器部分在 shell 提供 `__DSH_TRANSPORT__.fetch` 时(Worker preview 隧道)经它读取 `/plugins/events`,否则经 `EventSource` 读取。因此在 Preview 里启用 bundle 后,其 Client 插件无需刷新页面即可挂载。
## 考虑过的替代方案
**保留打包期组合,只在 Worker 里包成一个合成 base bundle。** 改动小,但合成 bundle 会出现在插件列表里,打包期“自己编”的配置也还在,与正式 Web 不同构。
**Worker 用同步 XHR 拉 chunk。** 不改 client-modules,但下载期间整棵 Host 树都被阻塞。
**隧道按 URL 识别 chunk 请求后预取。** 需要在隧道里复制 client-modules 的 `/plugins/<id>/<file>` 路由规则。
**按包或按大小阈值合并 archive。** 每次 `require.async` 只要一个文件,按 chunk 拆分最贴合;阈值是没有依据的可调参数。
**把 Node v24 的 CJS 加载器和解析器源码搬进 Worker,替换 Worker 的整条 require 流程。** 解析路径可以与 Node 逐字一致,但要改用 Node 的条件集(镜像内容随之变化)、引入 cjs-module-lexer 计算具名导出,worker.js 约增加 300 KB;而调用方只需要 internal 模块的接口。
**给 `readPluginMeta` 注入解析器。** 要改 app-boot 的公共函数来适配一个平台差异,配置 schema 导出等其他 `fromInternal()` 调用方仍然拿不到加载器。
**模拟 `--expose-internals`。** vendor Loader、HMR 和 app-boot 在没有这个参数时都退到 `node-addon-require-builtin`,替换这个包即可覆盖全部调用方。
## 后果
Preview 启动的组合与正式 Web 相同。不开 bundle 时,Worker 组合与正式 Web 只差 jsonl 的平台覆盖;开任一 bundle 都没有被跳过的补丁。插件页和“设置 → 内置插件”列出全部 `OPTIONAL_BUNDLES`,每个都显示标题和图标,没有元信息错误。11 个 optional bundle 逐个开启再关闭,两次都返回 `applied`;开启后它改动的每个启用行都是 active,关闭后插件列表恢复原样。启用 session-titles 会禁用 `@deepseek-ai/dsh-session-title-first-prompt-llm`(行 `session-title-llm`)并启用 `@deepseek-ai/dsh-session-title-all-prompts-llm`,与正式 Web 一致;启用 inspector-profile 后 Session Inspector 运行,NodeJS Inspector 降级启动。降级以 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 为前提,所以正常 `dsh web` 运行完整的 Inspector。
体积(MiB,实测):基础镜像 9.20(打包期组合且 chunk 随启动下载时为 12.79,−28%),含 11 个 optional bundle;另有 5 个 lazy tar 共 4.45(pdf 2.88、excel 1.39、terminal 0.13、frontmatter 0.05、inspector 底部面板 0.00)。基础镜像不含懒加载 chunk,页面请求 chunk 时才拉取对应 archive。
这一取舍的代价:
- HMR 不替换模块:Worker 的 ES module 缓存始终为空,VFS 里被修改的模块文件仍运行已加载的代码,而 HMR 照常报告 `applied`。Preview 镜像不会修改模块文件(包操作会因 `execa` 拒绝而失败)。
- Node internal 模块里只提供成员名的部分被调用时拒绝;将来有调用方真正调用它们,会在 Preview 里明确失败。
- `process.versions.node` 报 `24.18.0` 可能改变按版本分支的第三方代码;扫描镜像只发现 undici 在 ppc64 上按版本选择 wasm SIMD,与 Worker 无关。
- CommonJS 的 `require('stream')` 返回模块命名空间而不是 `Stream` 构造函数,把它传给 `util.inherits` 的代码会在加载时失败。
- optional bundle 里浏览器跑不了的能力(sqlite、终端、本地语音)按“跑到才炸”处理:启用不报错,用到时报错。NodeJS Inspector 降级后只剩返回 503 的 bootstrap 路由。
- 镜像布局里没有 `config/cordis.yml`,`WorkerHostOptions` 没有 `configPath`,缺少 `profile` 的镜像 manifest 无法通过校验,所以带组合配置打出的镜像必须重新打包。
- 延迟文件要求读取方走会等待的异步接口;新增的同步读取方会在 Preview 里明确失败,而不是退化。Worker 只校验到达的 archive 中登记的路径和大小,不校验命名它的摘要。
## 测试
- [profile-image.spec.ts](../../../../packages/experimental/webworker-packer/tests/profile-image.spec.ts) 锁定收包清单(模板 bundle、全部 optional bundle、它们补丁行指向的包),以及记录 profile 和 installation、只带安装清单的镜像。
- [lazy-chunks.spec.ts](../../../../packages/experimental/webworker-packer/tests/lazy-chunks.spec.ts) 锁定每个 `require.async` chunk 一个 archive、多次打包得到相同的 archive 名和字节、拒绝未发布的 chunk、`./client` 导出目标留在基础镜像。
- [lazy-archive.spec.ts](../../../../packages/experimental/webworker-runtime/tests/storage/lazy-archive.spec.ts) 锁定登记后的作答、`ERR_DSH_VFS_DEFERRED` 拒绝、重命名目标保持不变、删除与 seed、共用一次拉取、拉取失败后重试,以及会等待的 `node:fs` 接口。
- [require-builtin.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/require-builtin.spec.ts) 锁定提供的 internal 模块、其他 id 以平台错误码拒绝、cascaded loader、HMR 替换的成员和报告的 Node 版本;[fs-watch-context.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/fs-watch-context.spec.ts) 锁定监听器在 watcher 创建时的上下文里运行。
- [bootstrap-route.host.spec.ts](../../../../packages/experimental/inspector/tests/bootstrap-route.host.spec.ts) 锁定 Inspector 降级;[transport.client.spec.ts](../../../../packages/client/hmr/tests/transport.client.spec.ts) 锁定经页面 transport 读取事件流;[node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) 锁定 chunk 读取重试。
- [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) 启动打包好的 Worker 部署,经隧道请求一个懒加载 chunk,逐个开关全部 optional bundle,检查 session-titles 和 inspector-profile 的行,并在不刷新页面的情况下挂载已启用 bundle 的 Client 插件。镜像体积是实测值,没有测试锁定。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-10-20-new-session-blank-reuse-by-preset.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 .agents/notes/implemented/architecture/2026-10-20-new-session-blank-reuse-by-preset.md
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset:
en: ff1fe8bacfb05cee
zh: b03fa775407f8aa2
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/problem:
en: 5e6529a1ff3c1af5
zh: 7d08e27fd9e4ccac
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/decision:
en: a06d2e45382320f0
zh: 3eb09fc03c69eced
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/alternatives-considered:
en: 895f9dedbb6d7abf
zh: bb882bd3cc962ed3
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/verification:
en: bcfd62fdd75b53c4
zh: 040ca441c67030c2
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/consequences:
en: 88d1152b67ab392d
zh: 8c95b9fa7c1b2a04
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/deferred-work:
en: a7a8e49adae7b4fc
zh: a835f0e39b981fc6
@@ -0,0 +1,51 @@
# Agent Note: New Session reuses a blank Session, matching a requested preset
Status: implemented
English | [中文](2026-10-20-new-session-blank-reuse-by-preset.zh.md)
## Problem
In 0.2.0-rc.2, New Session (the sidebar button, the Workspace "+" button, and the shortcut, all calling `uiWorkspace.startSession` without options) reused the Workspace's first blank Session. PR #5775, first released in `dsh-v0.2.1-alpha.2`, changed it to create a fresh Session and Agent on every call, so that New Session composes the current preset definition after the user changes one instead of keeping a reused blank's earlier composition ([upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md)).
A blank Session that is not current is hidden from the Session tree, and New Session is the only way back to it. After that change, a user who types a draft into a blank Session, switches to another Session, and clicks New Session lands in another empty Session; the original draft stays in localStorage under the old Session id, but no control returns to it. 0.2.1-rc.2 therefore loses New Session's unsent drafts.
The Creator entries (`startSession(…, { agentPreset: 'cordis', prompt, clearPreviousDraft: false })`) also reuse the first blank Session and switch it to Creator: an existing ordinary draft stays under Creator mode and the Creator prompt is not filled in, and New Session can likewise take over a Creator blank.
## Decision
Only the Client (`ui-workspace`) changes; the Host is unchanged:
- `startSession` without options takes the reuse path again and follows the same rule as `openWorkspace`, `connectWorkspace`, and `startSession` with options.
- `createInWorkspace` names the first unstarted, unarchived blank Session that is still a Workspace member. A request that names a preset skips a blank whose listed preset (`projectionValues.agentPreset`) is a different one, while a blank with no listed preset remains eligible; a request without a preset may name any blank, which the Host switches to its default preset. The Host creates a Session when no blank is eligible.
As a result, New Session returns to the original blank Session with its unsent draft, and preset flows such as the Creator entries do not take over a blank that lists another preset, so an ordinary draft is not moved under Creator. No persisted state is added, and the Client does not read the preset roster.
## Alternatives considered
**Revert the PR #5775 change completely, reusing the first blank for every request.** Drafts come back, but preset flows such as the Creator entries take over the blank holding the user's ordinary draft, switch it to Creator, keep the draft there, and cannot fill in their prompt.
**Keep creating a fresh Session and move the abandoned blank's draft into it.** This needs rules for which blank to move from when several exist, how to treat attachments, and whether to archive the old Session, which is more change and state than reusing blanks.
**Let a request without a preset reuse only a blank of the Host default preset.** The Client does not know the default preset and would have to request the `agentPresets.list()` roster.
**Record a persisted source-to-blank-Session map in the Client.** It duplicates the preset each blank already records and must handle stale entries and per-origin isolation.
**Clear the existing draft in place when the preset changes (`clearPreviousDraft: true`).** It deletes a draft the user was writing in the other mode.
**Also change the Host to always call `presets.select` when reusing a loaded blank.** It fully keeps #5775's goal but is outside this Client-only fix; see Deferred work.
## Verification
- `ui-workspace` unit tests: New Session without options returns to the existing blank; a request without a preset reuses any blank, a request with a preset reuses only a blank listing the same preset, and a new Session is created when none matches. `navigation.ts` has 100% per-file coverage.
- Regression localization: the same unit probe on the PR #5775 commit that made this change and on its parent, plus a browser reproduction on builds of both, confirmed that the draft loss comes from that change.
## Consequences
- New Session no longer guarantees the current preset definition: a reused blank whose Agent is still loaded in the Host with the same preset id keeps its creation-time composition; a blank resumed after a Host restart composes the current definition.
- New Session may reuse a blank left by a preset flow such as the Creator entries; the Host switches it back to the default preset and its draft stays.
- The [new-session-blank-reuse](../../../../docs/upgrade-guide/v0.2.1-rc.2/new-session-blank-reuse/guide.md) upgrade guide records the change relative to 0.2.1-rc.2.
## Deferred work
- Host `reuseLive` (`packages/api/session-controller/src/agent.ts`) returns the loaded Agent unchanged when the preset id matches and does not call `presets.select`. `select` decides whether a definition was registered again by comparing `Generation` objects: when they are the same it does nothing; when they differ it recomposes under the same Session id with the current definition and records `agent-preset/selected`; on a started Session it throws `agent-preset/locked`, and the caller creates a new Session instead. Removing the `target === current` early return and always calling `select` keeps drafts while meeting #5775's goal that New Session composes the current definition; it needs a Host test, and updates to the `reuseLive` JSDoc and to the upgrade guide's statement that the current preset definition is no longer guaranteed.
@@ -0,0 +1,51 @@
# Agent Note: 新会话复用空白会话,指定预设时按预设匹配
Status: implemented
[English](2026-10-20-new-session-blank-reuse-by-preset.md) | 中文
## 问题
0.2.0-rc.2 中,新会话(左侧栏按钮、工作区“+”和快捷键,均调用不带选项的 `uiWorkspace.startSession`)复用工作区内第一个空白会话。PR #5775(首次发布于 `dsh-v0.2.1-alpha.2`)改为每次新建 Session 与 Agent,目的是在用户修改预设定义后,新会话按当前定义装配,而不是沿用复用空白会话的旧装配([升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md))。
空白会话不是当前会话时不在 Session 树中显示,返回它的唯一入口是新会话。改为每次新建后,用户在空白会话中写下草稿、切到其他会话再点新会话,会进入另一个空的 Session;原草稿仍按会话 id 保存在 localStorage,但没有界面入口可以回到它。0.2.1-rc.2 因此丢失新会话的未发送草稿。
此外,创造模式入口(`startSession(…, { agentPreset: 'cordis', prompt, clearPreviousDraft: false })`)复用第一个空白会话并把它切换到 Creator:已有普通草稿时,草稿留在 Creator 模式下,Creator 提示语不会填入;反之新会话也可能接管 Creator 的空白会话。
## 决策
只修改前端(`ui-workspace`),Host 不变:
- 不带选项的 `startSession` 重新走复用分支,与 `openWorkspace`、`connectWorkspace` 和带选项的 `startSession` 使用同一规则。
- `createInWorkspace` 在未开始、未归档、仍属于该工作区的空白会话中选第一个。请求指明预设时,跳过列出的预设(`projectionValues.agentPreset`)为其他值的空白会话,未列出预设的空白会话仍可用;请求未指明预设时任何空白会话都可用,由 Host 切换到默认预设。没有可用的空白会话时由 Host 新建。
结果:新会话回到原来的空白会话,未发送的草稿保留;创造模式等预设流程不会接管列出其他预设的空白会话,用户的普通草稿不会被切到 Creator 下。不新增持久化状态,前端也不读取预设名单。
## 考虑过的替代方案
**完全回退 PR #5775 的这项改动,所有请求都复用第一个空白会话。** 草稿可以回来,但创造模式等预设流程会接管用户普通草稿所在的空白会话,把它切到 Creator,草稿留在 Creator 下,提示语也填不进去。
**保持每次新建,把被丢下的空白会话中的草稿迁移到新会话。** 需要定义多个空白会话时迁移哪一个、附件如何处理、旧会话是否归档,改动和状态都比按预设复用多。
**未指明预设的请求只复用 Host 默认预设的空白会话。** 前端不知道默认预设,需要额外请求 `agentPresets.list()` 名单。
**在前端记录“来源 → 空白会话 id”的映射并持久化。** 与空白会话自身记录的预设重复,需要处理映射过期、按浏览器 origin 隔离等问题。
**预设变化时在原会话上清空草稿(`clearPreviousDraft: true`)。** 会删除用户在另一种模式下写到一半的草稿。
**同时修改 Host,让复用内存中的空白会话时总是调用 `presets.select`。** 能完整保留 #5775 的目的,但不在本次只修前端的范围内,见“推迟工作”。
## 验证
- `ui-workspace` 单元测试:不带选项的新会话回到已有空白会话;未指明预设时复用任何空白会话,指明预设时只复用列出相同预设的空白会话,没有时新建。`navigation.ts` 逐文件覆盖率 100%。
- 回归定位:在 PR #5775 中做出这项改动的提交及其父提交上运行同一单元探针,以及在两侧构建上用浏览器复现,确认草稿丢失由该改动引入。
## 后果
- 新会话不再保证使用当前预设定义:复用的空白会话若其 Agent 仍在 Host 内存中且预设 id 相同,沿用创建时的装配;Host 重启后恢复的空白会话按当前定义装配。
- 新会话可能复用创造模式等预设流程留下的空白会话,Host 把它切回默认预设,其中的草稿保留。
- 升级指南 [new-session-blank-reuse](../../../../docs/upgrade-guide/v0.2.1-rc.2/new-session-blank-reuse/guide.zh.md) 记录相对 0.2.1-rc.2 的行为变化。
## 推迟工作
- Host `reuseLive`(`packages/api/session-controller/src/agent.ts`)在预设 id 相同时直接返回内存中的 Agent,不调用 `presets.select`。`select` 用定义的 `Generation` 对象是否相同判断定义是否重新注册过:相同则不做任何事;不同则在同一会话 id 上按当前定义重新装配并记录 `agent-preset/selected`;会话已开始时抛出 `agent-preset/locked`,调用方改为新建。去掉 `target === current` 的提前返回、改为总是调用 `select`,可以在保留草稿的同时满足 #5775“新会话按当前定义装配”的目的;需要配套 Host 测试,并更新 `reuseLive` 的 JSDoc 和上述升级指南中“不再保证当前预设定义”的说明。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-22-deepseek-official-model-listing.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 .agents/notes/implemented/architecture/2026-10-22-deepseek-official-model-listing.md
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized:
en: 20de6c2a1bc643b4
zh: 2fe21450cebf7f32
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/problem:
en: 51a513e6134e6ad9
zh: e0b19412c9b88e24
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/decision:
en: ceb2f960085577c3
zh: 1bbc833b7cf24070
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/alternatives-considered:
en: fa126e4f4e0790b4
zh: c4f6d266d1ba6753
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/consequences:
en: 047e30a0f55acb24
zh: 804e15532d172157
@@ -0,0 +1,36 @@
# Agent Note: The official DeepSeek route follows the official model listing until its catalog is customized
Status: implemented
English | [中文](2026-10-22-deepseek-official-model-listing.zh.md)
## Problem
The `deepseek-official` route offered whatever its `models` setting held, and that setting defaults to the catalog bundled with each build. A model the DeepSeek API added reached API-key users only through a release, while the account route already followed the service's `GET /models` listing at the same origin. Unlike the account route's catalog, the official route's catalog is user-editable: the Models page stores an edited catalog in `models`, and a gateway behind `baseURL` declares its models there.
## Decision
- [`llm-deepseek-api-key`](../../../../packages/llm/llm-deepseek-api-key/README.md) follows `GET https://api.deepseek.com/models`, authenticated with the API key in `x-api-key`, only while the Messages origin is `https://api.deepseek.com` and `models` equals the built-in catalog. Any other endpoint or catalog sends no listing request.
- Loading, refresh, fallback, merging, and the client-version gate are the [account route's](2026-09-24-deepseek-account-remote-model-catalog.md): a first access waits up to `modelsTimeoutMs`, a listing older than `modelsMaxAgeMs` refreshes in the background on the next access, a failure keeps the last good listing or the built-in catalog, and the cache lives in memory, keyed by endpoint and key fingerprint.
- A customized `models` list wins whole: it replaces the catalog and stops listing requests. A list is customized when it resolves differently from the built-in catalog, because the schema default materializes an absent `models` as the built-in list and the Models page shows that default as its inherited rows.
- Switching between a customized catalog and the listing invalidates the cache from a `loader/volatile-update` listener, so Restore defaults reloads the listing on the next access even when the cached listing is fresh.
- The listing request, its cache, catalog resolution, and the gate live in the shared [Messages transport](../../../../packages/llm/llm-deepseek/README.md) because two routes consume them; the transport's provider hooks remain `connect` and `admit`.
## Alternatives considered
**Follow the listing on every endpoint.** A gateway that publishes an OpenAI-style `/models` returns entries without `api_capabilities.anthropic_messages`, which the decoder hides, so the route would offer no models. Every such gateway would also receive an extra request carrying the key.
**Merge the customized catalog with the listing by model id.** Customized users would keep receiving new listed models, but a model the user deleted would return, and the Models page cannot hide one listed model.
**Mark an unset `models` by removing its schema default.** An absent value would select the listing without comparing catalogs, but the Models page would lose the inherited rows it shows and copies, with their hidden `systemPromptUpdate` and `toolUpdate` fields, into a customized catalog. Rows copied from the generic model-discovery API would drop those fields.
**Fetch once per process, or refresh on a timer.** A single fetch leaves a long-running Web or desktop host on its startup catalog, and one transient failure pins it to the built-in catalog. A timer sends requests while the route is idle. Access-triggered background refresh matches the account route.
**Reuse the cached listing after Restore defaults.** The cache could serve a listing loaded before the customization for up to `modelsMaxAgeMs`; the request is cheap, so restoring always reloads it. The reload starts on the next access rather than inside the listener, which keeps credential lookup and its failures on the request path; the Web page's settings refresh makes that access right after the save.
## Consequences
- API-key users on the official endpoint receive listed models without a release, and an older build hides or refuses a model that needs a newer client, as account users' builds do.
- A customized catalog receives no later listed models until Restore defaults, and a `models` list identical to the built-in catalog counts as uncustomized. The comparison uses the running version's built-in catalog, so a saved list equal to an earlier version's built-in catalog counts as customized once the built-in catalog changes.
- The Models page shows the built-in rows while the listing is followed, so a model only the listing offers appears only in model selectors, and editing the page's catalog drops it.
- The first catalog access on the official endpoint can wait up to `modelsTimeoutMs`, and a suite that boots the route there must answer the listing itself to stay off the network.
@@ -0,0 +1,36 @@
# Agent Note: DeepSeek 官方路由在目录未自定义时跟随官方模型列表
Status: implemented
[English](2026-10-22-deepseek-official-model-listing.md) | 中文
## 问题
`deepseek-official` 路由提供的是 `models` 设置里的内容,而这个设置默认是每个构建自带的目录。DeepSeek API 新增的模型只能随发版到达 API key 用户,而账号路由已经在同一个 origin 上跟随服务端的 `GET /models` 列表。与账号路由不同,官方路由的目录可以由用户编辑:模型页把编辑后的目录存进 `models`,`baseURL` 后面的网关也在这里声明自己的模型。
## 决策
- [`llm-deepseek-api-key`](../../../../packages/llm/llm-deepseek-api-key/README.zh.md) 只在 Messages 的 origin 是 `https://api.deepseek.com`、且 `models` 等于内置目录时,跟随 `GET https://api.deepseek.com/models`,并用 `x-api-key` 携带 API key 鉴权。其他端点或目录不发列表请求。
- 加载、刷新、兜底、合并与客户端版本门控沿用[账号路由](2026-09-24-deepseek-account-remote-model-catalog.zh.md)的规则:首次访问最多等待 `modelsTimeoutMs`,列表超过 `modelsMaxAgeMs` 后在下一次访问时后台刷新,失败时保留上一份成功的列表或使用内置目录,缓存只在内存中,以端点和 key 指纹为键。
- 自定义的 `models` 列表整份优先:它替换目录,并停止列表请求。解析结果与内置目录不同的列表才算自定义,因为 schema 默认值会把缺省的 `models` 物化为内置列表,模型页也把这个默认值显示为继承的行。
- 在自定义目录与列表之间切换时,一个 `loader/volatile-update` 监听器会让缓存失效,因此点恢复默认后,即使缓存的列表尚未过期,下一次访问也会重新加载。
- 列表请求、缓存、目录解析和门控放在共享的 [Messages 传输包](../../../../packages/llm/llm-deepseek/README.zh.md)中,因为现在有两条路由使用它们;传输层的提供方钩子仍然只有 `connect` 和 `admit`。
## 考虑过的替代方案
**在所有端点上跟随列表。** 发布 OpenAI 风格 `/models` 的网关返回的条目没有 `api_capabilities.anthropic_messages`,解码器会隐藏它们,路由就不提供任何模型。每个这样的网关还会多收到一个携带 key 的请求。
**按模型 id 合并自定义目录与列表。** 自定义用户会继续收到新列出的模型,但用户删掉的模型会重新出现,而模型页无法隐藏某一个列出的模型。
**去掉 `models` 的 schema 默认值来标记未设置。** 缺省值不用比较目录就能选中列表,但模型页会失去它显示的继承行;这些行连同隐藏的 `systemPromptUpdate`、`toolUpdate` 字段会被复制进自定义目录。从通用模型发现 API 复制的行会丢掉这些字段。
**每个进程只获取一次,或按定时器刷新。** 只获取一次会让常驻的 Web 或桌面宿主停留在启动时的目录,一次偶发失败就会让它固定在内置目录。定时器会在路由空闲时发请求。由访问触发的后台刷新与账号路由一致。
**恢复默认后沿用缓存的列表。** 缓存可能在 `modelsMaxAgeMs` 内一直提供自定义之前加载的列表;列表请求代价很小,所以恢复默认总是重新加载。重新加载从下一次访问开始,而不是在监听器里进行,这样凭据查询及其失败都留在请求路径上;Web 页面保存后的设置刷新会立即发起这次访问。
## 影响
- 使用官方端点的 API key 用户无需发版就能看到列出的模型,旧构建会像账号用户的构建一样隐藏或拒绝需要新版客户端的模型。
- 自定义目录在恢复默认之前收不到之后列出的模型;与内置目录完全相同的 `models` 列表视为未自定义。比较的是当前版本的内置目录,所以保存的列表若等于旧版本的内置目录,内置目录变化后就算作自定义。
- 跟随列表期间,模型页仍显示内置的行,因此只有列表提供的模型只出现在模型选择器里,在模型页编辑目录会丢掉它。
- 官方端点上的第一次目录访问可能等待最多 `modelsTimeoutMs`,在该端点上启动路由的测试套件必须自己应答列表请求,才能不访问网络。
@@ -9,8 +9,8 @@
en: bab62798c2dc07b4
zh: fe42c5abf9127d37
/agent-note-durable-agent-teams-over-continuable-children/decision:
en: 95e7d723d6b00930
zh: d18fdf9b2bf69bdb
en: 35650a30529c01f8
zh: 4f9ee01b568d2fed
/agent-note-durable-agent-teams-over-continuable-children/profile-delegation:
en: 4835fb8c0c0d8a0d
zh: 5f9438a0d58104c4
@@ -16,7 +16,7 @@ Agent Teams needs an explicit opt-in composition while its public contracts rema
Every ordinary runtime root is the implicit Lead of a Team identified by that root's `SessionId`. The Team has no creation event: its Lead pseudo-row exists by identity, while durable state begins with the first member, message, or task event. A roster is flat and contains at most the configured number of immutable lowercase-kebab-case names. Each teammate is a continuable direct child with a reserved Session id; only the Lead creates or interrupts teammates. Ordinary provider-owned subagents outside the roster are not Team members, and an ordinary fork is a new root whose inherited Team records are excluded by their ancestor `TeamId`.
The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The explicit delegation policy permits Team creation only when the user asks for Agent Teams or teammates. Both packages are public members of `packages/experimental/`; the [experimental package reference](../../../../packages/experimental/AGENTS.md) owns publication, dependency isolation, and promotion.
The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The shared policy announces Team availability and describes coordination responsibilities. Both packages are public members of `packages/experimental/`; the [experimental package reference](../../../../packages/experimental/AGENTS.md) owns publication, dependency isolation, and promotion.
The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit.
@@ -16,7 +16,7 @@ Agent Teams 的公开约定仍处于实验阶段,因此需要显式启用的
每个普通运行时 Root 都是一个隐式 Team 的 Lead,Team id 等于该 Root 的 `SessionId`。Team 没有 creation event:Lead pseudo-row 由身份直接存在,持久状态从第一条 member、message 或 task event 开始。roster 是扁平结构,最多包含配置数量、不可变且采用小写 kebab-case 的名字。每个 teammate 都是使用预留 Session id 的 continuable 直接 child;只有 Lead 可以创建或 interrupt teammate。roster 外由 provider 管理的普通 subagent 不是 Team member;普通 fork 是新的 Root,继承的 Team 记录会因 ancestor `TeamId` 被排除。
实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。显式 delegation 策略只允许在用户要求 Agent Teams 或 teammate 时创建 Team。两个包都是 `packages/experimental/` 的公开成员;[实验性包参考](../../../../packages/experimental/AGENTS.md)负责发布、依赖隔离与 promotion。
实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。共享策略声明 Team 可用,并说明协调责任。两个包都是 `packages/experimental/` 的公开成员;[实验性包参考](../../../../packages/experimental/AGENTS.md)负责发布、依赖隔离与 promotion。
Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-02-plugin-market-discover.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 .agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md
/agent-note-plugin-market-discover-page-and-index-reader:
en: e226087ae345768b
zh: b043fa6c793fd684
/agent-note-plugin-market-discover-page-and-index-reader/problem:
en: 329c6ff947559b11
zh: 5c4c1053236d319b
/agent-note-plugin-market-discover-page-and-index-reader/decision:
en: cc4d17a51dd479b7
zh: 1fecf669b117f51c
/agent-note-plugin-market-discover-page-and-index-reader/alternatives-considered:
en: 62e7691dc8efd5c0
zh: b13769634979b950
/agent-note-plugin-market-discover-page-and-index-reader/consequences:
en: 672397a936cae42f
zh: 1f535708b914a915
@@ -0,0 +1,56 @@
# Agent Note: Plugin market Discover page and index reader
Status: implemented
English | [中文](2026-10-02-plugin-market-discover.zh.md)
## Problem
The [plugin market index format](2026-10-02-plugin-market-index-format.md) lets a market publish installable bundles in one document, but DSH needs a reader that fetches indexes safely, a place where users choose index sources and browse entries, and a path from an entry to the existing installation flow. No official market exists, so DSH cannot ship a default source; the sources are user data.
The plugin manager owns profile package operations and must not remount while an installation runs. Market configuration that a user edits from the Web therefore cannot be an ordinary configuration field of `plugin-manager`.
## Decision
The feature ships as the experimental optional [custom plugin markets bundle](../../../../packages/experimental/plugin-market-bundle/README.md), off by default, which mounts both rows below; with it off, neither the service, the Discover tab, nor the `plugin_market` tool exists, and the Plugins page shows no tabs. A separate Host service, [`pluginMarket`](../../../../packages/boot/plugin-market/README.md), reads indexes. It requires `pluginManager` for read-only calls and never writes the profile. Its `indexes` configuration field is volatile: Settings writes only volatile fields, and the Loader commits a volatile-only change into the running reference without remounting any plugin. Other fields (`fetchTimeoutMs`, `maxBytes`, `cacheTtlMs`) are ordinary configuration.
Index URLs use `http:` or `https:`, without credentials or fragment. `file:` is excluded because relative icon references would resolve to URLs a browser cannot load. `search` and `entry` accept only a configured URL, so the Host never fetches an address a client supplies. The Host fetches through the process proxy, refuses redirects, bounds the body, and validates with the shared format package; an invalid index affects only its own URL.
Indexes can reach the default 128 MiB `maxBytes`, so clients never receive or filter a whole index. `search` filters, matches text, orders, and pages on the Host and returns at most 100 entries; `entry` returns one plugin by name for the tool. Text matching is fuzzy and covers the package name, author, keywords, and every language of the title, description, and category and label titles, because a person may search in any language a market writes. A Chinese, Japanese, or Korean run is indexed as overlapping character pairs and its last character, so queries match without a word dictionary. [MiniSearch](https://github.com/lucaong/minisearch) builds the text index on the first text query of each document and yields between chunks. Each page carries its document's `fetchedAt`, and a later page sent with it is answered from that document while it is cached, so paging stays consistent across cache expiry; a client whose document was replaced starts again from the first page.
Each returned entry carries the profile's installation status, its compatibility computed by `evaluatePluginCompatibility` with the profile's version exemptions, and `install`, the exact arguments from `marketInstallTarget`. That is the only place install arguments are built; the Web page and the model tool consume it.
The Web [Discover tab](../../../../packages/client/ui-plugin-market/README.md) is its own client plugin. [`ui-plugin-manager`](../../../../packages/client/ui-plugin-manager/README.md) declares a `plugins.tab` slot and shows **Installed** and each contributed tab under the page header, because browsing a market and managing installed bundles are separate tasks of equal rank. The owner props provide `install(spec, registry?)` for a spec the person confirmed in the tab, which opens the existing install dialog at its check screen without the spec form, and `openPackage(name)`, which opens an installed bundle's page. A registry an entry names applies to that install only and is never remembered as the dialog's choice, so one market cannot redirect later manual installs. Discover confirms each install with the entry's origin and a trust notice, and keeps source management, which adds a code origin, in a dialog with its own trust notice. Each entry has a page with its full description, source facts, and every listed version; **Install** on a card chooses the default version, and the page can install any other version installation accepts. Indexes are browsed one at a time; categories, labels, and sort keys stay opaque and display in localized title order; entries missing the selected sort value come last in both directions.
A read-only `plugin_market` tool lists indexes and pages entries, returning the `target` and `registry` that `plugin_manager`'s `install_bundle` accepts. It requires no approval; installation keeps `plugin_manager`'s approval. The tool cannot change index sources, because adding a source introduces a new code origin.
## Alternatives considered
**Market fields on `plugin-manager`.** A non-volatile field would remount `plugin-manager` when the Web edits it and interrupt package operations it owns. Even with a volatile field, a separate service guarantees that market configuration never reaches `plugin-manager`, at the cost of one more package and service.
**Configuring indexes only in `cordis.yml`.** Most users do not edit `cordis.yml`, so Discover would stay empty for them.
**Fetching indexes in the browser.** CORS, the Host's proxy configuration, and validation where untrusted data enters favor Host fetches.
**Filtering in the browser.** It needs the whole annotated index in every client, which grows with the index and recomputes compatibility for every entry on each read.
**A query protocol on the index host.** It would keep large indexes off the Host but requires a server for every market, which a static index file avoids. It remains the next step if indexes outgrow the Host.
**Merging all configured indexes into one list.** Opaque ids from different markets may collide with different meanings, and duplicate package names need a conflict rule. Per-index browsing avoids both.
**Rendering Discover inside `ui-plugin-manager`.** The page would depend on the market Remote and grow further; a slot keeps the page usable without the market and lets the market plugin own its copy and state.
**Discover as a section under the Installed groups, installing through the spec form.** Finding it requires scrolling past every installed bundle, and the form asks a person to review a spec they never typed. Tabs give browsing its own entry point, and a confirmation with the entry's origin followed by the check screen replaces the form.
**An approval for `plugin_market`.** Listing changes nothing and reads only sources the user configured; requiring approval would add a prompt without protecting an operation. The installation step keeps its approval.
**An upgrade action.** Upgrading changes the documented "uninstall, then install" behavior and is deferred; Discover shows `update-available` as a status.
## Consequences
- Users browse and install third-party bundles without knowing a spec, and the existing install dialog, registry fallback, build-script approval, and compatibility refusal apply unchanged.
- The Settings form also shows `indexes`, because Settings lists every volatile field.
- An `http:` index, including the install specs it lists, can be altered in transit; a market reached over an untrusted network needs `https:`.
- URL icons are requested by the browser and disclose the user's IP address to image hosts; Discover loads them without a referrer.
- Market text reaches users and, through `plugin_market`, models; the tool description marks it as third-party content.
- A real-composition test proves that `setIndexes` persists through the profile without remounting the service, and unit tests cover fetch limits, cache sharing, removal, and search matching, ordering, and paging.
@@ -0,0 +1,56 @@
# Agent Note: 插件市场发现页与索引读取器
Status: implemented
[English](2026-10-02-plugin-market-discover.md) | 中文
## 问题
[插件市场索引格式](2026-10-02-plugin-market-index-format.zh.md)让市场能在一份文档中发布可安装的组合包(bundle),但 DSH 还需要:一个安全获取索引的读取器,一个让用户选择索引源并浏览条目的位置,以及一条从条目进入现有安装流程的路径。目前没有官方市场,因此 DSH 无法随附默认源;索引源属于用户数据。
插件管理器拥有 profile 的包操作,安装运行期间不得重新挂载。因此,用户从 Web 编辑的市场配置不能是 `plugin-manager` 的普通配置字段。
## 决策
该功能以实验性的可选[自定义插件市场组合包](../../../../packages/experimental/plugin-market-bundle/README.zh.md)发布,默认关闭,并挂载下面两行;关闭时,服务、「发现」标签页和 `plugin_market` 工具都不存在,插件页也不显示标签页。由独立的 Host 服务 [`pluginMarket`](../../../../packages/boot/plugin-market/README.zh.md) 读取索引。它依赖 `pluginManager` 进行只读调用,从不写入 profile。它的 `indexes` 配置字段是 volatile 字段:Settings 只写入 volatile 字段,Loader 把只涉及 volatile 字段的变更提交到运行中的引用,不重新挂载任何插件。其他字段(`fetchTimeoutMs`、`maxBytes`、`cacheTtlMs`)是普通配置。
索引 URL 使用 `http:` 或 `https:`,不含凭据或片段。排除 `file:`,因为相对图标引用会解析为浏览器无法加载的 URL。`search` 和 `entry` 只接受已配置的 URL,因此 Host 从不获取客户端提供的地址。Host 通过进程代理获取,拒绝重定向,限制响应体大小,并用共享的格式包校验;无效索引只影响它自己的 URL。
索引可能达到默认 128 MiB 的 `maxBytes`,因此客户端从不接收或过滤整个索引。`search` 在 Host 上完成过滤、文本匹配、排序和分页,最多返回 100 个条目;`entry` 按名称为工具返回一个插件。文本匹配是模糊的,覆盖包名、作者、关键词,以及标题、描述、分类标题和标签标题的所有语言,因为用户可能使用市场所写的任意语言搜索。中文、日文或韩文片段被索引为相邻的两字组合及其最后一个字符,因此查询无需分词词典即可匹配。[MiniSearch](https://github.com/lucaong/minisearch) 在每份文档第一次带文本的查询时构建文本索引,并在分块之间让出事件循环。每页都携带其文档的 `fetchedAt`,之后带着它发送的页请求只要该文档仍在缓存中就用它回答,因此分页在缓存过期后仍保持一致;文档被替换的客户端从第一页重新开始。
每个返回的条目携带 profile 中的安装状态、由 `evaluatePluginCompatibility` 结合 profile 版本豁免计算出的兼容性,以及 `install`,即 `marketInstallTarget` 给出的精确参数。这是唯一构建安装参数的位置;Web 页面和模型工具都使用它。
Web 的[发现标签页](../../../../packages/client/ui-plugin-market/README.zh.md)是独立的 Client 插件。[`ui-plugin-manager`](../../../../packages/client/ui-plugin-manager/README.zh.md) 声明 `plugins.tab` slot,并在页头下方显示**已安装**和每个贡献的标签页,因为浏览市场与管理已安装组合包是两项同等重要的独立任务。owner props 提供 `install(spec, registry?)` 和 `openPackage(name)`:前者用于用户已在标签页中确认的 spec,在检查界面打开现有的安装对话框,不显示 spec 表单;后者打开已安装组合包的页面。条目指定的注册表只用于这一次安装,从不作为对话框的选择被记住,因此一个市场无法改变之后手动安装所用的注册表。发现标签页在每次安装前显示条目来源和信任提示以供确认,并把会引入代码来源的索引源管理放在带有自身信任提示的对话框中。每个条目都有一个页面,显示其完整描述、来源信息和列出的每个版本;卡片上的**安装**选择默认版本,页面可以安装安装检查接受的任何其他版本。索引一次浏览一个;分类、标签和排序键保持不透明,按本地化标题顺序显示;缺少所选排序值的条目在两个方向上都排在最后。
只读的 `plugin_market` 工具列出索引并分页列出条目,返回 `plugin_manager` 的 `install_bundle` 接受的 `target` 和 `registry`。它不需要批准;安装仍保留 `plugin_manager` 的批准。该工具不能修改索引源,因为添加索引源会引入新的代码来源。
## 考虑过的替代方案
**把市场字段放在 `plugin-manager` 上。** 非 volatile 字段在 Web 编辑它时会重新挂载 `plugin-manager`,中断它拥有的包操作。即使使用 volatile 字段,独立服务也能保证市场配置永远不会到达 `plugin-manager`,代价是多一个包和服务。
**只在 `cordis.yml` 中配置索引。** 大多数用户不编辑 `cordis.yml`,发现区对他们会一直为空。
**在浏览器中获取索引。** CORS、Host 的代理配置,以及在不可信数据进入处进行校验,都支持由 Host 获取。
**在浏览器中过滤。** 这需要每个客户端都拿到整个带注解的索引,其大小随索引增长,并且每次读取都要为每个条目重新计算兼容性。
**在索引主机上提供查询协议。** 它能让大索引不进入 Host,但每个市场都需要一台服务器,而静态索引文件不需要。如果索引超出 Host 的承受能力,这是下一步。
**把所有已配置索引合并成一个列表。** 不同市场的不透明 id 可能相同但含义不同,重复的包名也需要冲突规则。按索引分别浏览可以避免这两个问题。
**在 `ui-plugin-manager` 内渲染发现区。** 该页面会依赖市场 Remote,代码量也会继续增加;使用 slot 时,没有市场该页面仍可使用,市场插件也能拥有自己的文案和状态。
**把发现作为「已安装」分组下方的区域,并通过 spec 表单安装。** 找到它需要滚动经过每个已安装组合包,而且该表单要求用户审核一个自己从未输入的 spec。标签页为浏览提供独立入口,先显示条目来源以供确认、再进入检查界面的流程取代了该表单。
**为 `plugin_market` 要求批准。** 列出操作不做任何改动,只读取用户配置的源;要求批准只会增加一次提示,而不保护任何操作。安装步骤保留其批准。
**升级操作。** 升级会改变已记录的「先卸载再安装」行为,因此延后;发现区把 `update-available` 显示为状态。
## 后果
- 用户无需知道 spec 即可浏览并安装第三方组合包,现有的安装对话框、registry 回退、构建脚本批准和兼容性拒绝照常适用。
- 设置表单也显示 `indexes`,因为设置会列出每个 volatile 字段。
- `http:` 索引(包括其中列出的安装 spec)可能在传输途中被篡改;经由不可信网络访问的市场需要使用 `https:`。
- URL 图标由浏览器请求,会向图片主机暴露用户的 IP 地址;发现区加载图标时不发送 referrer。
- 市场文本会到达用户,并通过 `plugin_market` 到达模型;工具描述将其标记为第三方内容。
- 一个真实组合测试证明 `setIndexes` 会经由 profile 持久化而不重新挂载服务,单元测试覆盖获取限制、缓存共享、移除,以及搜索的匹配、排序和分页。
@@ -0,0 +1,46 @@
# Bilingual-pair consistency record for 2026-10-02-plugin-market-index-format.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 .agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md
/agent-note-plugin-market-index-format:
en: ee3515adb22d4af6
zh: 5dfd687255f64e95
/agent-note-plugin-market-index-format/problem:
en: ce5e4a362f558af6
zh: 29d93e0655b99217
/agent-note-plugin-market-index-format/decision:
en: 906ac925fc53dd87
zh: affcc405e186b0d7
/agent-note-plugin-market-index-format/decision/example:
en: 1b1c960c966cf912
zh: 97be87cb14893c26
/agent-note-plugin-market-index-format/decision/root-fields:
en: 4fac5a9b8261342d
zh: f9168a8a4209feab
/agent-note-plugin-market-index-format/decision/plugin-fields:
en: b79abd05a957948b
zh: b6f0c203ea8e4838
/agent-note-plugin-market-index-format/decision/version-fields:
en: d6c0fa99e1cbb068
zh: 3d2a7b85299c679a
/agent-note-plugin-market-index-format/decision/install-arguments:
en: c748cdc4d8ce349a
zh: 5740fa0d6106e637
/agent-note-plugin-market-index-format/decision/dsh-compatibility:
en: 00fe507b9b5dfea7
zh: 55de6aaeb3865e84
/agent-note-plugin-market-index-format/decision/generator-obligations:
en: 923849223cb1e70e
zh: f3daf398bc06c81a
/agent-note-plugin-market-index-format/decision/reader-rules:
en: 92903cbe87d9c0ca
zh: 54af6cb33e9da252
/agent-note-plugin-market-index-format/alternatives-considered:
en: 908407ed974101d2
zh: b4b7391c513bda60
/agent-note-plugin-market-index-format/consequences:
en: 4aee5ff31901049a
zh: 8187b6216bb8b06b
/agent-note-plugin-market-index-format/risks:
en: 41170d483b17df80
zh: 1bfd8f44e0f09036
@@ -1,6 +1,6 @@
# Agent Note: Plugin market index format
Status: proposed
Status: implemented
English | [中文](2026-10-02-plugin-market-index-format.zh.md)
@@ -12,9 +12,9 @@ The smallest possible index is a list of npm names or Git repository URLs, one p
An index must therefore carry everything a list page displays: localized title and description, icon, author, available versions, compatibility, and publish time. Markets also want to publish their own data, such as categories, tags, downloads, and ratings, without DSH knowing what those values mean.
## Proposal
## Decision
Separate the **index** from the **index generator**. DSH defines and reads one JSON index format. How an index is produced, and from which source list, belongs to the generator. DSH's reader, its Discover page, and a reference generator are separate changes that consume this format. Indexes list [profile plugin bundles](../../implemented/architecture/2026-08-05-profile-plugin-bundles.md) and install them through [guided plugin installation](../../implemented/architecture/2026-09-15-guided-plugin-installation.md).
The **index** is separate from the **index generator**. DSH defines and reads one JSON index format. How an index is produced, and from which source list, belongs to the generator. [`dsh-plugin-market-index`](../../../../packages/util/plugin-market-index/README.md) is the executable rule set; the [Discover Agent Note](2026-10-02-plugin-market-discover.md) records how DSH reads and presents indexes. Indexes list [profile plugin bundles](../../implemented/architecture/2026-08-05-profile-plugin-bundles.md) and install them through [guided plugin installation](../../implemented/architecture/2026-09-15-guided-plugin-installation.md).
The market reuses npm's package names, versions, ranges, distribution tags, and person declarations. Readers and generators use maintained npm parsers and validators. Additional fields or changed meanings require a plugin-market requirement that npm does not meet.
@@ -45,6 +45,7 @@ The market reuses npm's package names, versions, ranges, distribution tags, and
"icon": "icons/auto-review.svg",
"author": "Example Labs",
"homepage": "https://example.com/dsh-auto-review",
"repository": { "url": "https://github.com/example/dsh-plugins", "directory": "auto-review" },
"metadata": {
"category": "productivity",
"labels": ["official"],
@@ -114,9 +115,10 @@ Category, label, and sort-key ids are non-empty strings. Readers look ids up as
| `name` | yes | Package name accepted by npm's maintained package-name validator for new packages; unique within the index. |
| `title` | no | `LocalizedText`; a reader displays `name` when absent. |
| `description` | no | `LocalizedText`. |
| `icon` | no | Either `data:<type>;base64,<payload>`, where `<type>` is `image/svg+xml`, `image/png`, `image/jpeg`, or `image/webp`, `<payload>` is canonically padded base64, and the decoded payload is at most 256 KiB; or a URL reference whose resolution against the index URL is an `https:` URL or has the index URL's origin. |
| `icon` | no | Either `data:<type>;base64,<payload>`, where `<type>` is `image/svg+xml`, `image/png`, `image/jpeg`, or `image/webp`, `<payload>` is canonically padded base64, and the decoded payload is at most 32 KiB; or a URL reference whose resolution against the index URL is an `https:` URL or has the index URL's origin. |
| `author` | no | String with at least one non-whitespace character. |
| `homepage` | no | Absolute `https:` or `http:` URL of a page about the plugin. |
| `repository` | no | `{ "url": string, "directory"?: string }`. `url` is the absolute `https:` or `http:` URL of the source repository's web page, without credentials, query, or fragment; `directory` is the package directory inside it, with the `path` rule of a Git source. It is for display and never selects an install source. |
| `metadata` | no | Object holding the values below. |
| `metadata.category` | no | One declared category id. |
| `metadata.labels` | no | Array of distinct declared label ids. |
@@ -166,7 +168,7 @@ A reader cannot verify these rules; a generator that violates them publishes a m
- `engines.dsh` copies the manifest's declared range unchanged. A version with a missing, blank, or invalid range is omitted with a diagnostic. A plugin with no eligible version is omitted.
- `dist-tags` snapshots npm's [distribution tags](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/), retaining only targets present among the eligible listed versions. For Git sources, the publisher or market supplies the same exact tag-to-version mapping.
- Each listed version declares `dsh.bundle`. Installation independently refuses a package without a bundle patch.
- `title`, `description`, and `icon` come from the highest listed version's locale metadata, manifest fields, and icon under the installed-plugin display rules. Use npm's `node-semver.rsort()` order, retaining input order for ties. This metadata choice does not select the installation version. `homepage` copies that version's manifest `homepage` and is absent without one.
- `title`, `description`, and `icon` come from the highest listed version's locale metadata, manifest fields, and icon under the installed-plugin display rules. Use npm's `node-semver.rsort()` order, retaining input order for ties. This metadata choice does not select the installation version. `homepage` copies that version's manifest `homepage` and is absent without one. `repository` comes from that version's manifest `repository`: a shorthand or Git URL becomes the repository's web URL, `directory` is copied when it meets the `directory` rule and otherwise dropped while `url` stays, and the field is absent when the manifest has none or names no web-addressable repository.
- `author` comes from the same highest listed version's npm author declaration. Copy an object's `name`; parse shorthand strings with a maintained npm person normalizer. Omit an absent or whitespace-only name and contact fields. Normalize a fresh projection, leaving the source manifest untouched.
### Reader rules
@@ -197,7 +199,7 @@ The initial channel is `latest`. Its exact target is the publisher- or market-se
**Display fields per version.** A list shows one row per plugin, so older display text, icons, authors, and homepages would cost index size without a reader that shows them.
**Inline icons only.** A list without extra requests needs inline icons, but 256 KiB of raw bytes is about 341 KiB as base64, so a 100-plugin index could reach about 35 MB. URL icons trade extra requests, and disclosure of the user's IP address to the image host, for a small index.
**Inline icons only.** A list without extra requests needs inline icons, but the 256 KiB installed-plugin icon limit is about 341 KiB as base64, so a 100-plugin index could reach about 35 MB. URL icons trade extra requests, and disclosure of the user's IP address to the image host, for a small index. The 32 KiB inline limit is about 43 KiB as base64, so 1,000 plugins with inline icons add at most about 43 MB; larger icons use URL references.
**Inferring host compatibility from peer dependencies.** The market defines a new standard requiring an author-declared `engines.dsh`. Existing packages must add that declaration before listing; no fallback, workspace mapping, or range intersection is needed.
@@ -215,7 +217,7 @@ The initial channel is `latest`. Its exact target is the publisher- or market-se
**Rejecting unknown members.** Every optional addition would then require a new `format` value and break every deployed reader.
## Acceptance criteria
## Consequences
- A validator accepts the example above and rejects one violation of each rule in Root fields, Plugin fields, Version fields, and the `source` forms, with a diagnostic naming the JSON path.
- Install arguments built from every example version are accepted by DSH's install-spec parser and by pnpm.
@@ -1,6 +1,6 @@
# Agent Note: 插件市场索引格式
Status: proposed
Status: implemented
[English](2026-10-02-plugin-market-index-format.md) | 中文
@@ -12,9 +12,9 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
因此,索引必须包含列表页面显示的全部内容:本地化标题和描述、图标、作者、可用版本、兼容性和发布时间。市场还需要发布自己的数据,例如分类、标签、下载量和评分,而 DSH 不需要理解这些值的含义。
## 提案
## 决策
将**索引**与**索引生成器**分开。DSH 定义并读取一种 JSON 索引格式。索引如何生成、基于哪份源列表生成,由生成器负责。DSH 的读取器、Discover 页面和参考生成器是使用此格式的独立变更。索引列出[profile 插件组合包](../../implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md),并通过[引导式插件安装](../../implemented/architecture/2026-09-15-guided-plugin-installation.zh.md)安装它们。
**索引**与**索引生成器**相互分离。DSH 定义并读取一种 JSON 索引格式。索引如何生成、基于哪份源列表生成,由生成器负责。[`dsh-plugin-market-index`](../../../../packages/util/plugin-market-index/README.zh.md) 是可执行的规则集;[发现 Agent Note](2026-10-02-plugin-market-discover.zh.md) 记录 DSH 如何读取和展示索引。索引列出[profile 插件组合包](../../implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md),并通过[引导式插件安装](../../implemented/architecture/2026-09-15-guided-plugin-installation.zh.md)安装它们。
市场复用 npm 的包名、版本、范围、发布标签和 person 声明。读取器与生成器使用维护中的 npm 解析器和校验器。新增字段或改变含义需要 npm 无法满足的插件市场需求。
@@ -45,6 +45,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
"icon": "icons/auto-review.svg",
"author": "Example Labs",
"homepage": "https://example.com/dsh-auto-review",
"repository": { "url": "https://github.com/example/dsh-plugins", "directory": "auto-review" },
"metadata": {
"category": "productivity",
"labels": ["official"],
@@ -114,9 +115,10 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
| `name` | 是 | npm 维护的包名校验器接受的新包名称;在索引内唯一。 |
| `title` | 否 | `LocalizedText`;缺省时读取器显示 `name`。 |
| `description` | 否 | `LocalizedText`。 |
| `icon` | 否 | 以下两种形式之一:`data:<type>;base64,<payload>`,其中 `<type>` 为 `image/svg+xml`、`image/png`、`image/jpeg` 或 `image/webp`,`<payload>` 是按规范填充的 base64,解码后的载荷不超过 256 KiB;或者 URL 引用,它相对于索引 URL 解析的结果是 `https:` URL,或与索引 URL 同源。 |
| `icon` | 否 | 以下两种形式之一:`data:<type>;base64,<payload>`,其中 `<type>` 为 `image/svg+xml`、`image/png`、`image/jpeg` 或 `image/webp`,`<payload>` 是按规范填充的 base64,解码后的载荷不超过 32 KiB;或者 URL 引用,它相对于索引 URL 解析的结果是 `https:` URL,或与索引 URL 同源。 |
| `author` | 否 | 至少包含一个非空白字符的字符串。 |
| `homepage` | 否 | 介绍该插件的页面的绝对 `https:` 或 `http:` URL。 |
| `repository` | 否 | `{ "url": string, "directory"?: string }`。`url` 是源代码仓库网页的绝对 `https:` 或 `http:` URL,不含凭据、查询或片段;`directory` 是包在仓库中的目录,规则与 Git 来源的 `path` 相同。该字段仅用于展示,从不决定安装来源。 |
| `metadata` | 否 | 包含下列值的对象。 |
| `metadata.category` | 否 | 一个已声明的分类 id。 |
| `metadata.labels` | 否 | 由互不相同的已声明标签 id 组成的数组。 |
@@ -166,7 +168,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
- `engines.dsh` 原样复制 manifest 声明的范围。缺失、空白或无效范围的版本会被省略并给出诊断。没有合格版本的插件会被省略。
- `dist-tags` 是 npm [发布标签](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/)的快照,只保留指向合格且已列出版本的目标。对于 Git 来源,发布者或市场提供同样的精确标签到版本映射。
- 列出的每个版本都声明了 `dsh.bundle`。安装时也会独立拒绝没有组合包 patch 的包。
- `title`、`description` 和 `icon` 来自所列最高版本的语言元数据、manifest 字段和图标,采用已安装插件的显示规则。使用 npm 的 `node-semver.rsort()` 顺序,相等时保留输入顺序。元数据选择不选择安装版本。`homepage` 复制该版本 manifest 的 `homepage`,manifest 没有该字段时省略。
- `title`、`description` 和 `icon` 来自所列最高版本的语言元数据、manifest 字段和图标,采用已安装插件的显示规则。使用 npm 的 `node-semver.rsort()` 顺序,相等时保留输入顺序。元数据选择不选择安装版本。`homepage` 复制该版本 manifest 的 `homepage`,manifest 没有该字段时省略。`repository` 来自该版本 manifest 的 `repository`:简写或 Git URL 转换为仓库的网页 URL,`directory` 符合 `directory` 规则时原样复制,否则丢弃并保留 `url`;manifest 没有该字段,或其指向的仓库没有网页地址时省略。
- `author` 来自同一最高版本的 npm 作者声明。复制对象的 `name`;使用维护中的 npm person 归一化器解析简写字符串。省略缺失或全空白的名称以及联系字段。归一化新建的投影,不修改源 manifest。
### 读取器规则
@@ -197,7 +199,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
**每个版本各有显示字段。** 列表每个插件只显示一行,旧的显示文本、图标、作者和主页会增加索引大小,却没有读取器显示它们。
**只允许内联图标。** 不发额外请求的列表需要内联图标,但 256 KiB 原始字节编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。
**只允许内联图标。** 不发额外请求的列表需要内联图标,但已安装插件的 256 KiB 图标上限编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。32 KiB 的内联上限编码为 base64 后约为 43 KiB,因此 1,000 个带内联图标的插件最多增加约 43 MB;更大的图标使用 URL 引用。
**从对等依赖推断宿主兼容性。** 市场定义要求作者声明 `engines.dsh` 的新标准。现有包必须添加此声明才能列出;不需要回退、workspace 映射或范围求交。
@@ -215,7 +217,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包
**拒绝未知成员。** 这样每次增加可选成员都需要新的 `format` 值,并会破坏所有已部署的读取器。
## 验收标准
## 后果
- 校验器接受上面的示例,并对根字段、插件字段、版本字段和 `source` 形式中的每条规则各拒绝一个违例,诊断信息给出对应的 JSON 路径。
- 根据每个示例版本构建的安装参数可被 DSH 的安装 spec 解析器和 pnpm 接受。
@@ -0,0 +1,34 @@
# Bilingual-pair consistency record for 2026-10-14-host-release-change-refresh-prompt.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 .agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases:
en: 559df9281e9994e0
zh: c51f2b1f9973127f
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/problem:
en: d98f5657dd30d9da
zh: 19d1210e57eb663b
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision:
en: d39be9788fe2a0c9
zh: 1a68a3ae4d73930d
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/behavior:
en: f85ebe23d677ae93
zh: a829e0692e711660
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/release-identity:
en: e7cae8876e688f92
zh: 1d326330c34d128d
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/wire:
en: f15c4106f228f5f6
zh: ab80cf4ce62ae84c
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/page-side:
en: c0610c8f5a0882f3
zh: d309cbc53183ec6e
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/ui:
en: 41b5a3346a7edffa
zh: a05ed84017556760
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/alternatives-considered:
en: abb9d7ab9ef1df15
zh: 1704732daf5f2886
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/consequences:
en: 815b41dc4f9ffcee
zh: b57388264cc459c7
@@ -0,0 +1,143 @@
# Agent Note: Prompt to reload when the Host release changes instead of hot-replacing across releases
Status: implemented
English | [中文](2026-10-14-host-release-change-refresh-prompt.zh.md)
## Problem
After `dsh web` is upgraded, or after a developer restarts `pnpm run dev:web` at another commit, an open browser page reconnects to the new Host automatically while its static shell stays at the old release. When the protocol is no longer compatible, the page keeps failing to reconnect or shows errors, and the user is not told that a reload is required.
Technical causes:
- After a reconnect, [client-hmr](../../../../packages/client/hmr/src/client/index.ts) receives the new Host's complete graph, and [ClientEntries](../../../../packages/client/modules/src/client/entries.ts) hot-replaces plugins by each row's `rev` (the bundle's mtime, ctime, and size). The Vite shell, static libraries, and the bootstrap are never replaced during the page's lifetime.
- The API connection validates its ready frame against an exact key set ([remote-events.ts](../../../../packages/api/gateway/src/client/remote-events.ts)), so an old page cannot recover from a protocol change by itself.
- The page has no way to learn that the Host now runs another release.
- Desktop is unaffected: after its Host is replaced, the main process navigates the window again ([main.ts](../../../../apps/desktop/src/main.ts)).
The [client plugin loading model](../architecture/2026-07-23-client-plugin-loading-model.md) owns the loading chain and hot replacement. It states that static libraries and the shell require a reloaded page; this note owns how the page learns that the Host's release changed and what it does about it.
## Decision
A release is the Host's version identity: a version plus an optional source commit. The name deliberately differs from "build", which names the build process and its artifacts.
The change touches three owners: client-modules (producing, publishing, and comparing the release identity), the shared client build preset (fixing the commit into the Host artifact), and ui-settings-general (the confirmation dialog). web-app and client-hmr are unchanged.
### Behavior
A page applies Host graphs and rebuilt frames only while the connected Host serves the release the page booted from. Every module the page runs therefore belongs to its boot release: either the boot artifacts unchanged, or the boot artifacts plus hot replacements published by a Host of that same release.
| Situation | Behavior |
|---|---|
| The Host serves the boot release; its graph equals the applied graph | Nothing happens. |
| The Host serves the boot release; some module revisions differ | Hot replacement of exactly the changed modules, as before; a shown dialog closes. |
| The Host serves another release (version or commit differs) | Nothing from it is applied, rebuilt frames are ignored, and a dialog shows the Host's and the page's transition. |
| The user chooses **Reload to load the new version** | `location.reload()` boots the page from the Host's release. |
| The user chooses **Not now**, the close control, a mask click, or Escape | The dialog closes; the page stays at its boot release and applies nothing from that Host release; later graphs of that release no longer ask. |
| The Host returns to the boot release | The dialog closes and the usual revision comparison replaces exactly the changed modules; unchanged modules stay. |
| The Host moves to a release other than the dismissed one | The dialog appears again. |
The page remembers only the last dismissed Host release, in page memory; nothing is written to localStorage or IndexedDB. After a reload, the new boot release is the comparison basis.
### Release identity
- Fields: `{ version, commit? }`; two releases are the same only when both fields are equal. Every complete build, released builds included, records the repository commit, so pages compare both fields; a release changes both together.
- `version` is client-modules' own package.json version. All packages release under one version, so it equals the CLI version.
- `commit` is fixed at build time. The shared client build preset [tsdown.client.ts](../../../../packages/client/tsdown.client.ts) injects the same public `process.env.DSH_CLIENT_*` values into the Node half of every client package that it injects into the browser half ([`clientBuildValueDefines`](../../../../scripts/client-build-environment.ts) replaces the named variables and leaves `process.env` itself in place). The client-modules Host code reads `process.env.DSH_CLIENT_COMMIT_HASH`, which is a constant in the built artifact.
- A Host started from TypeScript source has no build step: `pnpm run dev:web` places the build values in the Host process environment, so the Host reads the same commit; a source launch without dev-web publishes the version alone.
- Field values are wire data that the dialog renders verbatim, without localization.
### Wire
```
WebBootRelease { version: string; commit?: string }
WebBootGraph {
rev; entries; batches // unchanged
/** Host release identity; absent from a Host that publishes none. */
release?: WebBootRelease
}
BootManifest { ...; release?: WebBootRelease }
```
- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) computes the release once at construction and `compose()` writes it into every graph; `release` stays outside `graph.rev`. There is no new config field and the composition is unchanged.
- `__DSH_BOOT__` and every SSE `graph` frame carry the field, so one graph frame carries both the Host release and its complete module graph; the client-hmr Host half is unchanged. A rebuilt frame comes from the same Host process as the graph frame before it on that SSE connection, so it inherits that frame's release.
- `parseBootManifest` accepts an absent `release`, rejects a non-string `version` or `commit`, and drops members outside the identity, so equality compares only those two fields. `readRelease` reads only the `release` member of a received graph, so a page compares releases before it validates anything else; a release it cannot read counts as absent. Older pages that do not know the field ignore it.
### Page side
The release comparison lives in [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts); `ClientEntries` consults it from `sync` and `reload`.
```
interface ClientReleaseChange {
/** Release this page booted from; every module the page runs belongs to it. */
readonly page: WebBootRelease | undefined
/** Release the Host served before its latest release change. */
readonly previous: WebBootRelease | undefined
/** Release of the connected Host. */
readonly host: WebBootRelease | undefined
}
class ReleaseChangeGate {
/** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */
readonly change: ObservableSnapshot<ClientReleaseChange | undefined>
/** Whether the connected Host serves the boot release; graphs and rebuilt frames apply only then. */
get matches(): boolean
/** Record a received graph's release and update the announcement; true when the graph may be applied. */
observe(release: WebBootRelease | undefined): boolean
/** Stop announcing the connected Host's release. */
dismiss(): void
}
class ClientEntries {
/** The gate's change. */
readonly releaseChange: ObservableSnapshot<ClientReleaseChange | undefined>
/** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */
dismissRelease(): void
}
```
Order of operations:
1. `ClientEntries` creates the gate from the boot manifest's `release`.
2. `sync(graph)` reads the graph's release with `readRelease` and passes it to `observe` before parsing the rest, so a graph of another release is announced even when this page cannot read its format. A graph of the boot release is parsed and reconciles as before, and the existing revision comparison replaces exactly the changed modules. A graph of another release returns without entering the reconciliation queue; its first appearance logs the Host's previous and current identities through `console.warn` and publishes the change unless that release was the last one dismissed.
3. `reload(id, rev)` (a rebuilt frame) returns at once while the Host serves another release.
4. `retry()` keeps reconciling the last applied graph, which always belongs to the boot release.
5. The UI's inject face reloads the page through `location.reload()`; `ClientEntries` does not navigate.
### UI
- Owner: [ui-settings-general](../../../../packages/client/ui-settings-general/src/client/index.ts), which already owns the connection pill (`ConnectionIndicator`) and the Desktop update prompt; its `inject` adds `modules`.
- It registers the `shell.overlay` entry `host-release-change` through `ctx.slots.inject('shell.overlay', ...)` and renders it with `Modal`. The inject face provides `hooks: { releaseChange: ctx.modules.entries.releaseChange }`, `dismissRelease`, and `reloadPage`.
- Copy lives in the zh/en locale dictionaries: a title, one sentence stating that live updates are paused until a reload, and two gray-labelled lines:
- **Service**: `previous` (gray) → `host` (solid), joined by a 1px solid green arrow because the Host already switched.
- **Page**: `page` (solid) → `host` (gray), joined by a 1px dashed gray arrow because only a reload completes the switch. Both connectors are a fixed 56px and do not stretch across the dialog.
- Each line shows versions when its two ends differ in version and commits otherwise; a release or field the page cannot read, including one the Host did not publish, shows **Unknown**. Screen readers read the two lines as text; the arrows are decorative.
- The buttons are **Not now**, which carries `data-modal-autofocus`, and **Reload to load the new version** (primary). The dialog opens without a user gesture, so initial focus keeps the page and an Enter typed into the composer cannot reload it and discard a draft. The close control, a mask click, and Escape act as **Not now** through the shared `Modal` close behavior.
## Alternatives considered
- **Call `location.reload()` automatically**: it can discard unsaved input, and the user should see and confirm the reload.
- **Carry the identity in the API ready frame (`ConnectionHostInfo`)**: when the protocol is incompatible, the ready frame itself may fail to parse, so detection would never run; pausing hot replacement would still belong to `ClientEntries`.
- **Let web-app compose the identity (CLI version + web-app version + commit) and pass it to modules through the `webApp` service and the composition**: it changes web-app's service interface, tests, docs, and the generated API catalog, and makes modules wait for web-runtime activation; the two versions release together, so reading them separately adds nothing.
- **Add a client-modules `commit` config field set from `!!js process.env.DSH_CLIENT_COMMIT_HASH` in the composition**: it reads the environment at run time, and released `dsh web` launches do not set the variable; the commit must be fixed at build time.
- **Read the CLI version by depending on app-boot from client-modules**: it adds a dependency edge, a dependency-policy classification, a tsconfig reference, and a lockfile change, while the package's own version is the same number.
- **Keep the decision logic inside `ClientEntries`**: reconciliation and release comparison are independent concerns; the separate gate keeps `ClientEntries` to a few added lines.
- **Let Not now apply the other release's plugin updates**: the page would run plugins of one release on the shell and static libraries of another, its state could no longer be named by one release, and returning to the boot release would need a second prompt whose two choices end in the same state.
- **Name the field `build`**: it is easily confused with the build process and build artifacts.
- **Reopen a permanently closed SSE stream in client-hmr**: in end-to-end restarts the 404 window never appeared, because the browser's own retry covered connection refusals; the gap is a documented limitation.
- **Store the decision in localStorage**: the decision concerns one page's code, and a reload replaces that code, so it should not outlive the page.
- **Remember every dismissed release**: a page that moves between several Host releases is rare, and asking again after another release appeared costs one click.
- **Keep the identity an opaque string and let the page split version and commit**: the display rule needs the two parts separately, and parsing a concatenated string breaks whenever its format changes.
- **Create a separate UI package**: it needs three more registrations, while ui-settings-general already owns connection state and update prompts.
## Consequences
- An upgraded Host or a dev Host at another commit never hot-replaces plugin code into a page of another release; the page keeps working at its own release until the user reloads.
- While the Host serves another release, plugin updates, including enabling or disabling a plugin on the Host, reach the page only after a reload.
- When a Host returns to the page's release, only modules whose revisions differ are replaced. Revisions come from artifact metadata, so a rebuild of identical code still replaces the rebuilt modules once, without a prompt.
- A composition without the dialog registered still pauses updates from another release and logs the warning; the user sees no prompt.
- The API connection and SSE are independent channels, so an old page can show a protocol error before the dialog appears.
- If a restarting Host answers `/plugins/events` with a non-200 status before registering the route, the browser closes that SSE stream for good, and the page cannot detect a release change until it reloads.
- A source launch without dev-web has no commit, so development detection falls back to version changes.
- Each tab prompts and remembers independently.
- Unit tests pin the gate through `ClientEntries` (same-release replacement, another release applying nothing, the return to the boot release replacing only changed modules, dismissal, absent releases), the manifest parser, the Host-published release, the shared preset's Node-half defines, and the dialog's rendering and callbacks.
@@ -0,0 +1,143 @@
# Agent Note: Host release 变化时提示刷新页面,而不是跨 release 热替换
Status: implemented
[English](2026-10-14-host-release-change-refresh-prompt.md) | 中文
## Problem
`dsh web` 升级后,或开发者在另一个 commit 上重启 `pnpm run dev:web` 后,已打开的浏览器页面会自动重连到新 Host,但页面的静态 shell 仍停在旧 release。协议一旦不兼容,页面会一直重连失败或报错,用户也不知道需要刷新。
技术原因:
- 重连后,[client-hmr](../../../../packages/client/hmr/src/client/index.ts) 会收到新 Host 的完整 graph,[ClientEntries](../../../../packages/client/modules/src/client/entries.ts) 按每行的 `rev`(bundle 的 mtime、ctime 和大小)热替换插件。Vite shell、静态库和 bootstrap 在页面生命周期内从不替换。
- API 连接按精确键集合校验 ready 帧([remote-events.ts](../../../../packages/api/gateway/src/client/remote-events.ts)),协议一变,旧页面无法自行恢复。
- 页面没有任何途径得知 Host 已换成另一个 release。
- Desktop 不受影响:Host 被替换后,main 进程会重新导航窗口([main.ts](../../../../apps/desktop/src/main.ts))。
[client plugin loading model](../architecture/2026-07-23-client-plugin-loading-model.zh.md) 负责加载链与热替换,它写明静态库和 shell 需要刷新页面;本 Note 负责页面如何得知 Host 的 release 变了,以及之后怎么处理。
## Decision
release 指 Host 的版本身份:版本号加可选的源码 commit。名字刻意区别于 build,后者指构建过程和构建产物。
改动落在三处:client-modules(产生、下发和比较 release 身份)、共享 client 构建预设(把 commit 固化进 Host 产物)、ui-settings-general(确认框)。web-app 和 client-hmr 不改。
### 行为
只有当前连接的 Host 提供的是页面启动时的 release,页面才应用 Host 的 graph 和 rebuilt 帧。所以页面运行的每个模块都属于启动 release:要么是原封不动的启动产物,要么是启动产物加上同一 release 的 Host 发布的热替换。
| 场景 | 行为 |
|---|---|
| Host 提供启动 release,graph 与已应用的相同 | 什么都不做。 |
| Host 提供启动 release,部分模块 revision 不同 | 照常只热替换变化了的模块;已弹出的对话框关闭。 |
| Host 提供另一个 release(版本号或 commit 不同) | 不应用它的任何内容,忽略 rebuilt 帧,对话框显示 Host 和页面的版本变化。 |
| 用户选择**刷新以加载新版本** | `location.reload()` 以 Host 的 release 启动页面。 |
| 用户选择**暂不刷新**、关闭按钮、点击遮罩或 Escape | 对话框关闭;页面保持启动 release,不应用该 Host release 的任何内容;该 release 的后续 graph 不再提示。 |
| Host 回到启动 release | 对话框关闭,照常按 revision 对比,只替换变化了的模块,没变的保持不动。 |
| Host 换到被撤回的那个以外的 release | 再次弹出对话框。 |
页面只在内存里记住最近一次撤回的 Host release,不写 localStorage 或 IndexedDB。刷新后以新的启动 release 为比较基准。
### Release 身份
- 字段:`{ version, commit? }`,两个字段都相等才算同一 release。每次完整构建(包括正式发布)都会记录仓库 commit,所以页面比较的是两个字段;发布新版本时两者一起变。
- `version` 取 client-modules 自己 package.json 的版本。所有包同一版本号发布,所以它等于 CLI 版本。
- `commit` 在构建时固化。共享 client 构建预设 [tsdown.client.ts](../../../../packages/client/tsdown.client.ts) 给每个 client 包的 Node 半侧注入与浏览器半侧相同的公开 `process.env.DSH_CLIENT_*` 取值([`clientBuildValueDefines`](../../../../scripts/client-build-environment.ts) 只替换具体变量,保留 `process.env` 本身)。client-modules 的 Host 代码读 `process.env.DSH_CLIENT_COMMIT_HASH`,在构建产物里是常量。
- 从 TypeScript 源码启动的 Host 没有构建这一步:`pnpm run dev:web` 把构建取值放进 Host 进程环境,因此读到的是同一个 commit;不经 dev-web 的源码启动只发布版本号。
- 字段值属于 wire 数据,对话框原样显示,不做本地化。
### Wire
```
WebBootRelease { version: string; commit?: string }
WebBootGraph {
rev; entries; batches // unchanged
/** Host release identity; absent from a Host that publishes none. */
release?: WebBootRelease
}
BootManifest { ...; release?: WebBootRelease }
```
- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) 在构造时计算一次 release,由 `compose()` 写入每个 graph;`release` 不计入 `graph.rev`。不新增配置项,组合配置不变。
- `__DSH_BOOT__` 和每个 SSE `graph` 帧都带这个字段,所以一个 graph 帧同时携带 Host 的 release 和完整模块图;client-hmr 的 Host 半侧不变。rebuilt 帧与同一条 SSE 连接上前一个 graph 帧来自同一个 Host 进程,因此沿用那一帧的 release。
- `parseBootManifest` 允许 `release` 缺省,拒绝非字符串的 `version` 或 `commit`,并丢弃身份以外的成员,因此相等判断只看这两个字段。`readRelease` 只读取收到的 graph 的 `release` 成员,所以页面先比较 release,再校验其余内容;读不出的 release 视为缺省。不认识该字段的旧页面会忽略它。
### 页面侧
release 比较在 [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts);`ClientEntries` 在 `sync` 和 `reload` 中调用它。
```
interface ClientReleaseChange {
/** Release this page booted from; every module the page runs belongs to it. */
readonly page: WebBootRelease | undefined
/** Release the Host served before its latest release change. */
readonly previous: WebBootRelease | undefined
/** Release of the connected Host. */
readonly host: WebBootRelease | undefined
}
class ReleaseChangeGate {
/** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */
readonly change: ObservableSnapshot<ClientReleaseChange | undefined>
/** Whether the connected Host serves the boot release; graphs and rebuilt frames apply only then. */
get matches(): boolean
/** Record a received graph's release and update the announcement; true when the graph may be applied. */
observe(release: WebBootRelease | undefined): boolean
/** Stop announcing the connected Host's release. */
dismiss(): void
}
class ClientEntries {
/** The gate's change. */
readonly releaseChange: ObservableSnapshot<ClientReleaseChange | undefined>
/** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */
dismissRelease(): void
}
```
调用顺序:
1. `ClientEntries` 以启动 manifest 的 `release` 创建 gate。
2. `sync(graph)` 先用 `readRelease` 读出 graph 的 release 交给 `observe`,再解析其余部分,所以即使页面读不懂另一个 release 的格式也会弹框。启动 release 的 graph 经过解析后照常调和,原有的 revision 对比只替换变化了的模块。其他 release 的 graph 直接返回,不进入调和队列;它首次出现时用 `console.warn` 记录 Host 之前和当前的身份并发布变化,除非它就是最近一次撤回的 release。
3. Host 提供其他 release 期间,`reload(id, rev)`(rebuilt 帧)立即返回。
4. `retry()` 继续调和上次应用的 graph,它始终属于启动 release。
5. UI 的 inject 面通过 `location.reload()` 刷新页面;`ClientEntries` 不负责导航。
### UI
- 归属:[ui-settings-general](../../../../packages/client/ui-settings-general/src/client/index.ts),它已经拥有连接状态药丸(`ConnectionIndicator`)和 Desktop 更新提示;其 `inject` 增加 `modules`。
- 它通过 `ctx.slots.inject('shell.overlay', ...)` 注册 `shell.overlay` 条目 `host-release-change`,用 `Modal` 渲染。inject 面提供 `hooks: { releaseChange: ctx.modules.entries.releaseChange }`、`dismissRelease` 和 `reloadPage`。
- 文案在中英 locale 字典里:一个标题、一句说明热更新已暂停直到刷新,以及两行灰色标签的版本:
- **服务**:`previous`(灰)→ `host`(实色),1px 绿色实线箭头,表示 Host 已经切换。
- **页面**:`page`(实色)→ `host`(灰),1px 灰色虚线箭头,表示刷新后才会切换。两条连接线固定 56px,不撑满对话框。
- 每行两端版本号不同时显示版本号,否则显示 commit;页面读不出的 release 或字段(包括 Host 没发布的)显示**未解析**。读屏把两行当文字读,箭头只是装饰。
- 按钮为**暂不刷新**(带 `data-modal-autofocus`)和**刷新以加载新版本**(主按钮)。对话框不是用户操作打开的,初始焦点落在保留页面一侧,用户在输入框里按下的回车不会刷新页面、丢掉草稿。关闭按钮、点击遮罩和 Escape 通过共用的 `Modal` 关闭行为等同**暂不刷新**。
## Alternatives considered
- **自动调用 `location.reload()`**:可能丢失尚未保存的输入,而且应当让用户看到并确认刷新。
- **把身份放进 API ready 帧(`ConnectionHostInfo`)**:协议不兼容时 ready 帧本身就可能解析失败,检测根本不会发生;暂停热替换仍然属于 `ClientEntries`。
- **由 web-app 拼身份(CLI 版本 + web-app 版本 + commit),通过 `webApp` 服务和组合配置交给 modules**:要改 web-app 的服务接口、测试、文档和生成的 API 清单,还让 modules 等待 web-runtime 激活;两个版本号一起发布,分开读取没有区别。
- **给 client-modules 加 `commit` 配置项,在组合配置里写 `!!js process.env.DSH_CLIENT_COMMIT_HASH`**:运行时读环境变量,而正式发布的 `dsh web` 不设置这个变量;commit 必须在构建时固化。
- **client-modules 依赖 app-boot 读取 CLI 版本**:要新增依赖边、依赖策略分类、tsconfig 引用和 lockfile 改动,而本包自己的版本就是同一个数。
- **把判断逻辑留在 `ClientEntries` 里**:调和与 release 比较是两件独立的事;拆出 gate 后 `ClientEntries` 只多几行。
- **选择暂不刷新后应用另一个 release 的插件更新**:页面会在一个 release 的 shell 和静态库上运行另一个 release 的插件,其状态无法再用一个 release 描述;回到启动 release 时还得再问一次,而两个选择的结果完全相同。
- **字段命名为 `build`**:容易和构建过程、构建产物混淆。
- **在 client-hmr 里重开被永久关闭的 SSE**:端到端重启测试中从未出现 404 窗口,浏览器自带的重试已覆盖连接被拒;这个缺口记为已知限制。
- **把选择写进 localStorage**:这个选择只关乎一个页面的代码,而刷新会替换这些代码,所以不应比页面活得更久。
- **记住每一个撤回过的 release**:页面在多个 Host release 之间来回切换的情况很少,出现其他 release 后再问一次只多一次点击。
- **身份保持一个不透明字符串,由页面拆出版本号和 commit**:显示规则需要分开这两部分,从拼接字符串里拆解,格式一变就会出错。
- **新建一个独立 UI 包**:要多做三处注册,而 ui-settings-general 已经拥有连接状态和更新提示。
## Consequences
- 升级后的 Host 或换了 commit 的开发 Host,永远不会把插件代码热替换进另一个 release 的页面;页面按自己的 release 继续工作,直到用户刷新。
- Host 提供其他 release 期间,插件更新(包括在 Host 上启用或停用插件)要等刷新后才会到达页面。
- Host 回到页面的 release 时,只替换 revision 不同的模块。revision 取自产物元数据,所以即使重新构建出的代码完全相同,也会把重建过的模块替换一次,不弹框。
- 组合里没有注册确认框时,仍会暂停来自其他 release 的更新并记录警告,用户看不到提示。
- API 连接和 SSE 是两条独立通道,旧页面可能在确认框出现之前就已显示协议错误。
- 重启中的 Host 若在注册路由前对 `/plugins/events` 返回非 200 状态,浏览器会永久关闭这条 SSE,页面在刷新前检测不到 release 变化。
- 不经 dev-web 的源码启动没有 commit,开发期只能检测到版本号变化。
- 每个标签页各自提示、各自记录。
- 单元测试通过 `ClientEntries` 固定 gate 的行为(同一 release 的替换、其他 release 不应用任何内容、回到启动 release 只替换变化了的模块、撤回、缺失 release),并覆盖 manifest 解析、Host 发布的 release、共享预设给 Node 半侧的 define,以及确认框的渲染与回调。
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for 2026-10-15-deepseek-search-fetch-api.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 .agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md
/agent-note-direct-deepseek-search-and-fetch-apis:
en: fb19be7e7fc65810
zh: 865aaed5b7f802cc
/agent-note-direct-deepseek-search-and-fetch-apis/problem:
en: d1aad580a4db9872
zh: 1eb3bac59353291d
/agent-note-direct-deepseek-search-and-fetch-apis/decision:
en: e70efbb3aa9d53b6
zh: 1d25eae2461346df
/agent-note-direct-deepseek-search-and-fetch-apis/decision/protocol-selection-and-deployment:
en: ec653b59d62ed95f
zh: 0d675616059930ba
/agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input:
en: a95618f172ef63ee
zh: 306556be09c56bdf
/agent-note-direct-deepseek-search-and-fetch-apis/decision/fetch-metadata-and-ownership:
en: 8051417b08739ed0
zh: c11d35a346014436
/agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration:
en: d5a8e36b545f1c04
zh: d741fd0789401c62
/agent-note-direct-deepseek-search-and-fetch-apis/decision/supplementary-messages-fix:
en: 738e32b488bbc1a1
zh: f33afe570f62599a
/agent-note-direct-deepseek-search-and-fetch-apis/alternatives-considered:
en: 641cbfefc76f2f95
zh: e3e718473eace85f
/agent-note-direct-deepseek-search-and-fetch-apis/consequences:
en: a55da02f4d3c06c0
zh: 3c1a624f03f34f28
/agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption:
en: 41f9a331c7aca397
zh: 17a0fd53e133c2f5
@@ -0,0 +1,84 @@
# Agent Note: Direct DeepSeek Search and Fetch APIs
Status: implemented
English | [中文](2026-10-15-deepseek-search-fetch-api.zh.md)
## Problem
DeepSeek web retrieval through Messages requires an auxiliary model request for each query. Harness extracts sources and citation excerpts but discards the generated answer. The dedicated Search and Fetch APIs provide structured sources and Markdown directly, so retrieval can run without Harness starting that auxiliary generation. Adopting these APIs requires explicit selection of both operations, independent deployment addresses, and a mapping of Fetch results onto the existing fetch result.
## Decision
The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools, and [web-search-deepseek](../../../../packages/web/web-search-deepseek/README.md) keeps Messages search. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields.
### Protocol selection and deployment
The APIs register as `deepseek-search-api` and `deepseek-fetch-api`; Messages search keeps `deepseek-official`. The setting **Use the new Web Search API** (**启用新版 Web 搜索 API**) on the Web search page writes `web.searchProvider` and `web.fetchProvider` together: on selects both APIs, and off restores Messages search with local HTTP fetch, the shipped selection. The switch defaults off and takes effect after saving, because the web service reads both selections for each operation. Messages remains the default until the Search API launch is announced, so existing users keep working search without configuration. Each operation resolves one settings snapshot, including its endpoint and credentials. Both APIs read one credential and one API base, because the guide serves them from one base and the switch selects them together.
The two protocols retain independent API bases. Messages keeps `https://api.deepseek.com/anthropic/v1` and appends `/messages`. Direct retrieval uses `web-deepseek-api.baseURL`, which defaults to the guide's public base `https://api.deepseek.com/v1/tools`, and appends `/search` or `/fetch`; the verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`. These prefixes are different and are never inferred from the hostname, so another deployment enters its complete base. The switch is already the explicit opt-in, so the documented public deployment serves as its default. A blank or unparseable base leaves both providers unavailable.
The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A Session on the DeepSeek account route sends its account token instead, as Messages search does; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users can enable direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments keep Messages search.
### Search results and model input
Direct Search sends `query`, `count`, `need_content: false`, and `ori_question`. `count` is the configured value from 1 to 50, defaulting to the service's 10, unless the request's result bound is smaller; the tool always sends its limit, 8 by default. `ori_question` is the text of the initiating Session's latest human prompt with whitespace collapsed and cut to the API's 128-character limit. The API rejects blank or longer values, so a search without such a prompt omits the field, and `sendOriginalQuestion: false` disables it. In 2026-10-16 probes, different values left result order, scores, and summaries unchanged. Queries pass through unchanged, including the documented site, language, date, exclusion, and exact-phrase operators. The service enforces the 1–128 Unicode-character query constraint. Empty `results` is successful.
The provider preserves service order and maps URL, title, `snippet`, and nullable `date` into shared sources. The current tool gives the same short excerpt to the model and search card. It does not consume `summary`, full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities.
Direct retrieval emits no auxiliary model-request event because Harness makes no such request. The normal tool-call and tool-result events retain the query and model-visible result. This says nothing about the search service's internal use of models. Returned text remains external, untrusted data.
### Fetch metadata and ownership
Fetch sends a URL and returns Markdown without an extra Harness summarization request. The API supplies neither a final redirected URL nor a target HTTP status, and the shared result has no title field. The provider keeps the requested URL, returns the Markdown body, and reports HTTP 200 for a successful response: the Fetch API answers 404 when the URL is unavailable, blocked by site policy, or returns an error, or when no Markdown can be extracted. On 2026-10-16 an unresolvable host returned HTTP 500 instead, so every non-success status is a retrieval failure rather than a specific target diagnosis. The shared fetch result and its consumers stay unchanged.
The HTTP provider retains its own public-address validation, connection pinning, and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content.
### Default migration
Changing the default to the Search API is a separate change after the launch is announced. API keys and account tokens both reach the public routes; deployments behind a gateway without `/v1/tools` routes need an explicit `web.searchProvider: deepseek-official` before the default changes, and a stored Messages selection keeps Messages.
### Supplementary Messages fix
The retained Messages path explicitly sends `thinking: { type: 'disabled' }` and records that same body before dispatch. This avoids reasoning work in an auxiliary request whose generated answer is discarded; it does not implement or control the new retrieval API. `tool_choice` remains omitted. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) owns compatibility for the optional logged field.
## Alternatives considered
**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request while retaining Messages as an explicit mode.
**Make the Search API the default now.** The launch is not announced, and some gateways expose only Messages; their users would lose working search.
**Reuse the Messages address or infer a route prefix.** The protocols and deployed prefixes differ. Separate bases preserve existing configurations and avoid sending a valid request to the wrong route.
**Require an explicit Search API base.** Enabling the switch is already an explicit choice, and an empty default would make every user copy the documented public address. Other deployments still override it.
**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside `web-search-deepseek` cannot change fetch routing, so turning it on left `web_fetch` on HTTP unless the user also edited `web.fetchProvider`; it also tied the APIs' lifecycle to Messages search, which the default migration may retire. Separate registrations let the web service's existing selection express the switch, and each package can change or retire alone.
**Share one transport between Messages search and the new APIs.** Both resolve credentials through the same seams, `ctx.credentials` and `ctx.deepseekAccount`, so a shared module would save only the dispatch and failure-mapping code. It would make the Messages package depend on the new API package and tie their releases, while the default migration may retire Messages search. Each package keeps its own transport until then.
**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential, and the switch selects them together; separate packages would duplicate the settings a user edits once. Each operation lives in its own module, so either API can change without the other.
**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. The page needs only the DeepSeek choice, and other providers keep their own settings pages.
**Let the model supply `ori_question` as a tool argument.** The API defines the field as the user's original question, which the latest human prompt already records. A tool argument would change the model-facing schema shared by every search provider and invite paraphrase.
**Keep HTTP fetch when the switch turns on.** The switch adopts the new DeepSeek retrieval APIs, and leaving fetch on HTTP made Fetch API adoption depend on a hand-edited `web.fetchProvider`. HTTP fetch, with its own access policy, remains the selection whenever the switch is off, and configuration can still select either fetch provider alone.
**Add an extraction tool or widen the fetch result.** The API retrieves one concrete web page as Markdown, which fits the existing fetch operation. Its success already implies a retrieved page, and the Markdown body carries the page heading, so widening the result would change every fetch consumer for little model-visible gain.
## Consequences
Retrieval no longer requires auxiliary model tokens in direct mode, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off.
Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The current tool can issue four queries concurrently and fails the batch if one query fails. The provider has no shared rate scheduler, automatic retries, or fallback; other Sessions or processes can exhaust the same account allowance.
## Verification and remaining API adoption
Provider tests cover request mapping, empty results, malformed responses, independent and default bases, account-token authentication, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the default base, and switching both selections. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day.
The following capabilities remain outside the implemented integration:
- Separate model-facing `summary` from display `snippet`, with per-source and total output budgets and matching Session records. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16.
- Expose search syntax in provider-appropriate tool guidance and define a bounded localization input before forwarding it. API `localization` is an IP address, not an inferred city or UI language.
- Retain quality metadata before defining selection policies. `rerank_score` and `authority` describe service assessments, not guarantees of truth; a hard cutoff can discard useful niche sources.
- Add cancellation-aware scheduling and bounded retry ownership. The guide recommends waiting before retrying 408, 429, and 5xx; 402 requires funding first, and other 4xx require a changed request. Local pacing cannot enforce an account-wide quota across unrelated processes.
@@ -0,0 +1,84 @@
# Agent Note: 直接接入 DeepSeek Search 与 Fetch API
Status: implemented
[English](2026-10-15-deepseek-search-fetch-api.md) | 中文
## 问题
通过 Messages 获取 DeepSeek 网页搜索结果时,每个查询都需要一次辅助模型请求。Harness 提取来源与引用摘录,却丢弃生成的回答。独立 Search 和 Fetch API 直接提供结构化来源与 Markdown,使检索无需由 Harness 发起这次辅助生成。接入这些 API 需要明确选择两种操作的提供方、独立配置部署地址,并把 Fetch 结果映射到现有抓取结果。
## 决策
2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它,[web-search-deepseek](../../../../packages/web/web-search-deepseek/README.zh.md) 保留 Messages 搜索。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段。
### 协议选择与部署
两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索保留 `deepseek-official`。网页搜索页上的设置 **Use the new Web Search API**(**启用新版 Web 搜索 API**)同时写入 `web.searchProvider` 和 `web.fetchProvider`:开启时选择两个 API,关闭时恢复内置选择,即 Messages 搜索与本地 HTTP 抓取。开关默认关闭,保存后生效,因为 Web 服务在每次操作时读取两项选择。在 Search API 正式发布公告之前,Messages 仍是默认值,已有用户无需配置即可继续使用搜索。每次操作解析一份设置快照,包含端点和凭据。两个 API 读取同一份凭据和同一个 API 基础地址,因为指南从同一基础地址提供它们,开关也同时选择它们。
两种协议分别保存 API 基础地址。Messages 保留 `https://api.deepseek.com/anthropic/v1`,追加 `/messages`。直接检索使用 `web-deepseek-api.baseURL`,默认值为指南中的公网地址 `https://api.deepseek.com/v1/tools`,追加 `/search` 或 `/fetch`;已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`。两者前缀不同,程序不会根据主机名推断,其他部署需填写完整基础地址。开关本身已是显式启用,因此以文档中的公网部署作为默认值。基础地址为空白或无法解析时,两个提供方都不可用。
指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。DeepSeek 账号路由上的会话改为发送其账号 token,与 Messages 搜索一致;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也可以启用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署继续使用 Messages 搜索。
### 搜索结果与模型输入
直接 Search 发送 `query`、`count`、`need_content: false` 和 `ori_question`。`count` 为 1 至 50 的配置值,默认采用服务的 10;请求的结果上限更小时以后者为准,工具总会发送其上限,默认为 8。`ori_question` 是发起会话中最近一条用户提示的文本,空白折叠后截取至 API 的 128 个字符上限。API 拒绝空白或更长的值,因此没有此类提示的搜索不发送该字段,`sendOriginalQuestion: false` 可将其关闭。2026-10-16 的探测中,不同取值未改变结果顺序、评分或摘要。查询原样传递,包括文档中的站点、语言、日期、排除和精确短语操作符。服务端执行查询长度为 1–128 个 Unicode 字符的约束。空 `results` 表示成功。
提供方保留服务端顺序,将 URL、标题、`snippet` 和可为 null 的 `date` 映射为共享来源。当前工具将同一段短摘要提供给模型和搜索卡片。它不使用 `summary`、完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。
直接检索不产生辅助模型请求事件,因为 Harness 没有发起该请求。普通工具调用与工具结果事件继续保留查询及模型可见的结果。这不代表搜索服务内部不使用模型。返回文本仍是外部不受信任的数据。
### 抓取元数据与职责
Fetch 发送 URL,返回 Markdown,无需 Harness 额外发起摘要请求。API 既不提供重定向后的最终 URL,也不提供目标 HTTP 状态,而共享结果没有标题字段。提供方保留请求 URL,返回 Markdown 正文,并对成功响应报告 HTTP 200:URL 不可用、被站点策略拦截、目标站点返回错误或无法提取 Markdown 时,Fetch API 会返回 404。2026-10-16 无法解析的主机实际返回 HTTP 500,因此任何非成功状态都视为抓取失败,而不是对目标的具体诊断。共享抓取结果及其消费方保持不变。
HTTP 提供方继续负责自身的公开地址校验、连接固定和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。
### 默认值迁移
将默认值改为 Search API 是发布公告之后的另一项变更。API 密钥和账号 token 都能访问公网路由;位于没有 `/v1/tools` 路由的网关之后的部署,需要在默认值变更前显式设置 `web.searchProvider: deepseek-official`,已保存的 Messages 选择仍使用 Messages。
### 附带的 Messages 修复
保留的 Messages 路径显式发送 `thinking: { type: 'disabled' }`,并在发送前记录同一请求体。这避免了辅助请求在生成回答被丢弃的情况下执行思考;它不实现或控制新的检索 API。`tool_choice` 仍不传递。[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)负责日志中可选字段的兼容性。
## 考虑过的替代方案
**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求,同时保留 Messages 供显式选择。
**立即将 Search API 设为默认值。** 尚未发布公告,部分网关也只提供 Messages;这些用户会失去可用的搜索。
**复用 Messages 地址或推断路由前缀。** 协议与部署前缀不同。分别保存基础地址可保留现有配置,避免把有效请求发往错误路由。
**要求显式填写 Search API 基础地址。** 开启开关已是显式选择,空默认值会迫使每位用户复制文档中的公网地址。其他部署仍可覆盖它。
**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** `web-search-deepseek` 内的开关无法改变抓取路由,因此开启后除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍走 HTTP;它还把两个 API 的生命周期绑在 Messages 搜索上,而默认值迁移后 Messages 搜索可能被移除。独立注册让 Web 服务现有的选择来表达这个开关,每个包都可以单独修改或移除。
**让 Messages 搜索与新 API 共用一套传输层。** 两者都通过同样的 seam,即 `ctx.credentials` 和 `ctx.deepseekAccount` 解析凭据,共享模块只能省下请求派发与失败映射代码。它会让 Messages 包依赖新 API 包、两者必须一起发版,而默认值迁移后 Messages 搜索可能被移除。在此之前,两个包各自保留传输层。
**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API,开关也同时选择它们;拆成两个包会让用户只需编辑一次的设置重复出现。每种操作位于独立模块,任一 API 都可以单独修改。
**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。该页面只需要 DeepSeek 的选择,其他提供方保留各自的设置页。
**让模型以工具参数提供 `ori_question`。** API 将该字段定义为用户的原始问题,而最近一条用户提示已记录了这段文本。工具参数会改变所有搜索提供方共用的面向模型的 schema,并引入改写。
**开启开关时保留 HTTP 抓取。** 该开关用于启用 DeepSeek 新的检索 API,保留 HTTP 抓取会让使用 Fetch API 依赖手动修改 `web.fetchProvider`。开关关闭时,抓取仍选择带有自身访问策略的 HTTP 提供方,配置中也仍可单独选择任一抓取提供方。
**新增提取工具或扩展抓取结果。** API 以 Markdown 获取一个具体网页,符合现有抓取操作。其成功本身即表示已获取页面,Markdown 正文也带有页面标题,因此扩展结果会改动每个抓取消费方,而模型可见的收益很小。
## 影响
直接模式中的检索不再需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。
Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。当前工具可并发发送四个查询,任一查询失败会使整批失败。提供方没有共享限速调度器、自动重试或回退;其他会话或进程也会消耗同一账号的额度。
## 验证与尚未接入的 API 能力
提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与默认地址、账号 token 鉴权、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、默认地址和同时切换两项选择。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。
以下能力尚未包含在已实现的接入中:
- 区分面向模型的 `summary` 与用于展示的 `snippet`,设置每个来源和总输出限额,并记录对应的 Session 内容。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。
- 在适用于提供方的工具说明中介绍搜索语法,先定义有界的本地化输入,再进行转发。API 的 `localization` 是 IP 地址,不能用推测的城市或界面语言代替。
- 先保留质量元数据,再定义筛选策略。`rerank_score` 与 `authority` 表示服务端评估,不保证内容真实;固定阈值可能丢弃有用的冷门来源。
- 增加支持取消的调度并明确有限重试的职责。指南建议对 408、429 和 5xx 等待后重试;402 需要先充值,其他 4xx 需要修改请求。本地节流无法跨无关进程执行账号级配额。
@@ -12,8 +12,8 @@
en: a15c7c13f167e5ac
zh: 63bcc2ef60c2f344
/agent-note-published-dependency-faces-and-bounded-peer-relays/decision/package-selection:
en: 5ea13b09ae774437
zh: ef9dbccada39107a
en: 931b678bcdb42dbe
zh: 95dbebdb03c50d1f
/agent-note-published-dependency-faces-and-bounded-peer-relays/decision/dependency-sections:
en: d34ebac6c1c7642b
zh: 2cf3a60df14b072a
@@ -14,7 +14,7 @@ The package that chooses a Client build input is the shipped profile, while a Ho
### Package selection
[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every non-experimental package that declares `dsh.client`. Inside the directory, `dsh.client` marks a Client/Host package whose Host entry is scanned; a package without that declaration is a Client-only static build input. Outside the directory, `dsh.client` selects the same Client/Host scan. A `"./client"` export alone is an API and does not select npm dependency policy.
[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every package that declares `dsh.client`, experimental packages included. Inside the directory, `dsh.client` marks a Client/Host package whose Host entry is scanned; a package without that declaration is a Client-only static build input. Outside the directory, `dsh.client` selects the same Client/Host scan. A `"./client"` export alone is an API and does not select npm dependency policy. [Experimental packages follow the same dependency declaration rules](2026-10-14-experimental-dependency-policy.md) records why the selection includes experimental packages.
[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) provides explicit Client-face include and exclude lists. An include handles an exceptional package without `dsh.client`, while an exclude removes an automatically discovered dual-face package outside `packages/client/`. The verifier rejects unknown, stale, redundant, duplicate, overlapping, and ineffective entries. The include list is empty; the exclude list contains `@deepseek-ai/dsh-api-session-controller` and `@deepseek-ai/dsh-api-workspace-controller`. Adding Session Controller back would migrate nine more Host edges while its five-run candidate retest improved median resolution by only 0.15 seconds.
@@ -14,7 +14,7 @@ Client 构建输入由发布 profile 选择,而 Host value import 由导入它
### 包选择
[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个非实验包。在该目录内,`dsh.client` 标记需要扫描 Host 入口的 Client/Host 包;没有该声明的包是仅供 Client 编译的静态输入。在目录外,`dsh.client` 选择相同的 Client/Host 扫描。仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。
[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个包,实验包也包括在内。在该目录内,`dsh.client` 标记需要扫描 Host 入口的 Client/Host 包;没有该声明的包是仅供 Client 编译的静态输入。在目录外,`dsh.client` 选择相同的 Client/Host 扫描。仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。[实验包遵守同一套依赖声明规则](2026-10-14-experimental-dependency-policy.zh.md)记录了选包为何包括实验包。
[`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 提供显式 Client 门面 include 与 exclude 列表。include 用于没有 `dsh.client` 的例外包,exclude 用于移除 `packages/client/` 之外自动发现的双面包。验证器拒绝未知、失效、冗余、重复、相互重叠和无法生效的配置项。include 列表为空;exclude 列表包含 `@deepseek-ai/dsh-api-session-controller` 和 `@deepseek-ai/dsh-api-workspace-controller`。把 Session Controller 加回会多迁移九条 Host 边,而五次候选复测的 resolver 中位数仅改善 0.15 秒。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-14-experimental-dependency-policy.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 .agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.md
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules:
en: ec44b9c40ce4bb25
zh: 2e5731c96fa82ba8
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/problem:
en: ee78aec260872b62
zh: 005e4c3c2b54a9ef
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/decision:
en: 081508647fade940
zh: 496cffe0fba8544f
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/alternatives-considered:
en: 28c320fb3547502d
zh: 9563fd1709ed8b54
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/consequences:
en: cc50e617e4893a30
zh: c2d2b9efd2955127
/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/testing:
en: 9542331ca97ac00a
zh: 34857e7b85128c7b
@@ -0,0 +1,40 @@
# Agent Note: Experimental packages follow the same dependency declaration rules
Status: implemented
English | [中文](2026-10-14-experimental-dependency-policy.zh.md)
## Problem
A [verify-package-dependencies](../../../../scripts/verify-package-dependencies.ts) scope of `packages/!(experimental)/*/package.json` exempts the whole experimental group from the rule that browser and type relationships belong only in devDependencies ([client dependency rules](../../../../packages/client/AGENTS.md#dependency-declaration)), and [published dependency faces](2026-08-26-published-dependency-faces.md) stated its coverage as "non-experimental packages". Under that scope, 4 experimental client packages declared client, api, session, and similar packages as peerDependencies. The webworker packer follows workspace peers into the image, so browser static libraries became sweep roots, and packing agent-team-profile produced 82 unresolvable requests. The [experimental package rules](../../../../packages/experimental/AGENTS.md) state that experimental status does not relax engineering requirements, which that exemption contradicts.
Checking the 6 experimental packages that declare `dsh.client` under the same rules found 46 violations across the full run, all in experimental packages. In addition, a verifier that traces runtime imports only from the Host entry misclassifies inspector's `ws`, which is used only in its worker_threads entry, as devDependencies-only.
## Decision
- The check reads `packages/*/*/package.json`, so experimental packages follow exactly the release rules with no group-level exemption, and the dependency-section check carries no other experimental exception.
- The verifier also scans each package's own Host-side worker and child-process entries, identified from the build entries the package's `tsdown.config.ts` declares. `readHostBuildEntries` imports that config, evaluates its default export once under the `host` build face and once under the `client` build face (`DSH_BUILD_FACE`), combines the results, and keeps entries whose platform is not browser and whose source is outside `src/client/`. The verifier does not guess from source patterns. The configurations can only be imported asynchronously, so the verifier's state reading is async.
- The affected experimental manifests and the lockfile follow the policy; every entry moved out of `peerDependencies` or `dependencies` was confirmed not to be a runtime requirement. cot-translation applies `instanceof` to translator's `TranslationError`, which requires the same class instance, so `peerRequiredHostExports` classifies that export.
- Host-only experimental packages, like release packages, are checked only when listed in `hostPackages`. Whether to extend the check to every Host-only package is a separate decision.
- This decision partially supersedes the package-selection coverage of [published dependency faces](2026-08-26-published-dependency-faces.md), whose package-selection paragraph links back here; that note's other decisions remain in force.
## Alternatives considered
**Fix only these manifests without changing the check scope.** The same declarations would soon return; a rule without an enforcement point is not a rule.
**Skip browser static libraries as sweep roots in the packer.** This only works around the symptom; the incorrect manifests still affect npm installation and other consumers.
## Consequences
`pnpm run verify-package-dependencies` covers experimental packages that declare `dsh.client`, so the manifests it accepts let the webworker packer pack agent-team-profile and voice-input-bundle, and inspector's published manifest keeps its worker runtime dependencies.
The trade-off costs:
- Published experimental manifests list browser and type relationships only as dev dependencies, so users who install these packages individually from npm receive fewer peer hints.
- The verifier cannot see imports injected by a tsdown `banner`, such as the inspector worker's `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`. Such a dependency stays only because it is declared; if it is removed by mistake, the verifier does not detect it.
- `readHostBuildEntries` imports the `tsdown.config.ts` of every covered package with a Host face and evaluates its default export under both build faces, so the gate depends on every such config module importing cleanly, and the modules' import side effects run in the gate process; for example, the inspector's config imports its package-local [`scripts/build-devtools.ts`](../../../../packages/experimental/inspector/scripts/build-devtools.ts).
- Measured: loading the 127 additional tsdown configurations takes about 2 s, and the full-run duration stays essentially the same (4:27 without the scan, 4:21 with it).
## Testing
[verify-package-dependencies.spec.ts](../../../../scripts/verify-package-dependencies.spec.ts) pins three cases: an experimental client package that declares a browser package as a peer is rejected, an experimental package listed in `hostPackages` is checked as a configured Host package, and a runtime dependency used only in a worker entry named by `tsdown.config.ts` stays in dependencies. The same spec pins the peer-required classification of translator's `TranslationError`.
@@ -0,0 +1,40 @@
# Agent Note: 实验包遵守同一套依赖声明规则
Status: implemented
[English](2026-10-14-experimental-dependency-policy.md) | 中文
## 问题
如果 [verify-package-dependencies](../../../../scripts/verify-package-dependencies.ts) 的检查范围是 `packages/!(experimental)/*/package.json`,整个 experimental 组就不受“浏览器和类型关系只放 devDependencies”([client 依赖规则](../../../../packages/client/AGENTS.md#dependency-declaration))的约束,[发布依赖面](2026-08-26-published-dependency-faces.zh.md)也曾把覆盖范围写成“非实验包”。在这一范围下,有 4 个实验 client 包把 client、api、session 等包写成 peerDependencies。webworker packer 会沿 workspace peer 把这些包收进镜像,浏览器静态库因此成了扫描起点,agent-team-profile 打包时出现 82 条无法解析的请求。[实验包规则](../../../../packages/experimental/AGENTS.md)写明实验身份不放宽工程要求,这一豁免与之矛盾。
按同一套规则检查 6 个带 `dsh.client` 的实验包,全量共 46 条违规,全部出在实验包。另外,只从 Host 入口追踪运行时导入的 verifier 会把 inspector 的 `ws` 误判为只该放 devDependencies,因为它只在 worker_threads 入口里使用。
## 决策
- 检查读取 `packages/*/*/package.json`,experimental 包与正式包遵守完全相同的规则,不设组级豁免,依赖区段检查也没有其他针对实验包的例外。
- verifier 同时扫描包自己的 Host 侧 worker 和子进程入口,识别依据是包 `tsdown.config.ts` 声明的构建入口。`readHostBuildEntries` import 这份配置,在 `host` 和 `client` 两个构建 face(`DSH_BUILD_FACE`)下各求值一次默认导出并取并集,保留 platform 不是 browser、源码不在 `src/client/` 下的 entry。不靠猜源码写法。配置只能异步 import,所以 verifier 的状态读取是 async。
- 受影响的实验包清单和 lockfile 符合策略;从 `peerDependencies` 或 `dependencies` 移走的每一项都确认过不是运行时必需。cot-translation 对 translator 的 `TranslationError` 做 `instanceof` 判断,需要同一个类实例,因此 `peerRequiredHostExports` 收录了这个导出。
- 纯 Host 的实验包与正式包一样,只有列入 `hostPackages` 才受检查。是否扩大到全部纯 Host 包,另行决定。
- 本决定部分取代[发布依赖面](2026-08-26-published-dependency-faces.zh.md)的选包覆盖范围,该 Note 的选包段落链接回本 Note;它的其余决定保持有效。
## 考虑过的替代方案
**只修这几个清单,不改检查范围。** 很快会再出现;规则没有执行点等于没有规则。
**在 packer 里跳过浏览器静态库这类扫描起点。** 只是绕开症状,错误的清单还会影响 npm 安装和其他消费方。
## 后果
`pnpm run verify-package-dependencies` 覆盖带 `dsh.client` 的实验包,它接受的清单让 webworker packer 能打包 agent-team-profile 和 voice-input-bundle,inspector 的发布清单也保留它的 worker 运行时依赖。
这一取舍的代价:
- 发布的实验包清单只把浏览器和类型关系列为 dev 依赖,通过 npm 单独安装这些包的用户得到的 peer 提示更少。
- verifier 看不到 tsdown `banner` 注入的导入,例如 inspector worker 的 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。这类依赖只因为已经声明而被保留;如果被误删,verifier 发现不了。
- `readHostBuildEntries` 会 import 每个受覆盖、带 Host face 的包的 `tsdown.config.ts`,并在两个构建 face 下求值其默认导出,所以门禁依赖每个这样的配置模块都能干净地 import,这些模块的 import 副作用也会在门禁进程里执行;例如 inspector 的配置会 import 包内的 [`scripts/build-devtools.ts`](../../../../packages/experimental/inspector/scripts/build-devtools.ts)。
- 实测:新增的 127 个 tsdown 配置加载约 2 s,全量耗时基本不变(不扫描时 4:27,扫描时 4:21)。
## 测试
[verify-package-dependencies.spec.ts](../../../../scripts/verify-package-dependencies.spec.ts) 锁定三种情形:实验 client 包把浏览器包写成 peer 时会被拒绝;列入 `hostPackages` 的实验包按 configured Host 包检查;只在 `tsdown.config.ts` 指定的 worker 入口里使用的运行时依赖保留在 dependencies。同一份 spec 还锁定 translator 的 `TranslationError` 属于 peer-required 分类。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-14-no-top-level-await-in-packages.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 .agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.md
/agent-note-package-sources-must-not-use-top-level-await:
en: d7409565b91b608c
zh: 855807c10ac7e44a
/agent-note-package-sources-must-not-use-top-level-await/problem:
en: 1735c60cec478b06
zh: 85ed83b2b804b39e
/agent-note-package-sources-must-not-use-top-level-await/decision:
en: 7459c979b58be927
zh: 569c68e1476f0129
/agent-note-package-sources-must-not-use-top-level-await/alternatives-considered:
en: 5f19be0393f39abc
zh: ed0414b54eb17678
/agent-note-package-sources-must-not-use-top-level-await/consequences:
en: d609ff0b1ce0c99a
zh: d60aa801ea72c2dd
/agent-note-package-sources-must-not-use-top-level-await/testing:
en: 55adbea10005be4b
zh: 8c95636d2a0fdad4
@@ -0,0 +1,45 @@
# Agent Note: Package sources must not use top-level await
Status: implemented
English | [中文](2026-10-14-no-top-level-await-in-packages.zh.md)
## Problem
The child-process entry `src/worker.ts` of [speech-to-text-sensevoice](../../../../packages/experimental/speech-to-text-sensevoice/README.md) ran `await startRecognitionServer(...)` at module scope. Top-level await makes module evaluation asynchronous: an importer's evaluation waits on it, and an entry failure becomes a module-loading failure instead of an error the entry handles explicitly. The same file is also the package's `./worker` export, and the browser Preview's module loader wraps module bodies as CommonJS, so it refuses to pack top-level await, which kept voice-input-bundle out of the image. No repository gate prevented this pattern; a repository-wide scan found two more cases outside bins: ssh's `src/helper-entry.ts` (the target of the `./helper` export) and inspector's `src/worker/entry.ts` (a worker_threads entry).
## Decision
- Entry modules use an explicit `async function main()`:
- sensevoice: a failure before readiness writes only the error message to stderr and exits with code 1 once the message is flushed (`process.exit(1)` in the write callback, because a loaded native recognizer would otherwise keep the process alive); stdout carries only one `{"port":N}` line, which the Host parses as readiness.
- ssh helper: `main()` holds the helper's try/catch/finally and signal handling; every failure is reported on stderr with the `dsh-ssh-sandbox:` prefix and exit code 127.
- inspector worker: the synchronous startup stays at module scope, the asynchronous part runs in `main()`, and a failure sends the failure frame before stopping.
- Gate [verify-no-top-level-await](../../../../scripts/verify-no-top-level-await.ts):
- **Scope**: `.ts` and `.tsx` files under `packages/*/*/src/**`.
- **Rule**: based on the TypeScript AST. An `await` expression, `for await`, or `await using` whose nearest function body is the module is a violation, including occurrences in top-level if statements, blocks, try statements, loops, switch statements, conditional expressions, export initializers, and computed member names. Occurrences in functions, methods, constructors, accessors, parameter defaults, and class static blocks are not violations.
- **Exclusions**: only bins are excluded, judged from declarations and syntax alone: the source file starts with `#!`, or it is the `src/<path>.ts|.tsx` that corresponds to a `lib/<path>.js` named by package.json `bin`. A process executes a bin directly, and no other module imports it. The current exclusions are the `src/bin.ts` files of webworker-packer and llm-mock-server, and a spec pins the list.
- **Corpus guard**: the gate fails when the corpus is empty, contains no `.tsx`, or completely omits a `src/` directory that contains TS.
- **Registration**: a shared hygiene gate in `run-gates`, running in the static lane and the hygiene aggregate. The rule text lives in the [package-surface defensive patterns](../../../../docs/defensive-patterns/package-surface.md).
## Alternatives considered
**Rely only on the webworker packer's refusal.** The packer checks only modules that enter the image, so packages outside the image could still introduce top-level await.
**Implement it as a lint rule.** oxlint has no existing rule that expresses "module scope" precisely. A syntax-level script gate is more controllable, and tests can pin every included and excluded form.
**Forbid it in bins too.** A process executes a bin directly and nothing imports it, so its top-level await does not affect loading any module.
## Consequences
Every importable package source evaluates synchronously, including packages that never enter the Preview image, and each entry handles its own startup failure; voice-input-bundle enters the Preview image.
The trade-off costs:
- Third-party packages and `apps/` are outside the scope; the page bootstrap script `apps/web/src/preview.ts` uses top-level await.
- sensevoice startup failure diagnostics carry only the error message, without a stack trace.
## Testing
- [verify-no-top-level-await.spec.ts](../../../../scripts/verify-no-top-level-await.spec.ts) pins every included and excluded form, the corpus guards, and the current exclusion list; [run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) pins the gate's registration. With the asynchronous startup of the three entries above at module scope, the gate reports 4 cases (1 in sensevoice, 1 in ssh, 2 in inspector) and fails.
- [worker-entry.spec.ts](../../../../packages/experimental/speech-to-text-sensevoice/tests/worker-entry.spec.ts) launches the sensevoice entry and pins that a failure before readiness exits with code 1, leaves stdout empty, and reports the reason on stderr without a stack trace.
- [helper-boundaries.spec.ts](../../../../packages/ssh/ssh/tests/helper-boundaries.spec.ts) launches the ssh helper entry, and [integration.host.spec.ts](../../../../packages/experimental/inspector/tests/integration.host.spec.ts) starts the inspector Worker through `startInspector`; no spec drives the inspector worker entry's startup-failure frame.
@@ -0,0 +1,45 @@
# Agent Note: 包源码禁止顶层 await
Status: implemented
[English](2026-10-14-no-top-level-await-in-packages.md) | 中文
## 问题
[speech-to-text-sensevoice](../../../../packages/experimental/speech-to-text-sensevoice/README.zh.md) 的子进程入口 `src/worker.ts` 曾在模块顶层 `await startRecognitionServer(...)`。顶层 await 让模块求值变成异步:导入方的求值被它挂住,入口的失败也变成模块加载失败,而不是入口自己显式处理的错误。这个文件同时是包的 `./worker` 导出,浏览器 Preview 的模块加载器按 CommonJS 包装模块体,遇到顶层 await 会拒绝打包,voice-input-bundle 因此无法进入镜像。仓库里当时没有任何门禁阻止这种写法;全仓扫描时,除 bin 外还有另外两处:ssh 的 `src/helper-entry.ts`(`./helper` 导出的目标)和 inspector 的 `src/worker/entry.ts`(worker_threads 入口)。
## 决策
- 入口模块用显式 `async function main()`:
- sensevoice:就绪前失败时只把错误消息写到 stderr,消息写完后以退出码 1 退出(在写入回调里调用 `process.exit(1)`,否则已加载的原生识别器会让进程一直存活);stdout 只有一行 `{"port":N}`,Host 把它解析为就绪信号。
- ssh helper:`main()` 承载 helper 的 try/catch/finally 和信号处理;每个失败都以 `dsh-ssh-sandbox:` 前缀写到 stderr,退出码为 127。
- inspector worker:同步启动部分留在模块顶层,异步部分在 `main()` 里运行,失败时先发 failure 帧再 stop。
- 门禁 [verify-no-top-level-await](../../../../scripts/verify-no-top-level-await.ts):
- **范围**:`packages/*/*/src/**` 下的 `.ts` 和 `.tsx`。
- **判定**:基于 TypeScript AST。最近的函数体是模块本身的 `await` 表达式、`for await`、`await using` 都算违规,包括顶层 if、块、try、循环、switch、条件表达式、export 初始化和计算成员名里的。函数、方法、构造器、访问器、参数默认值、class static block 里的不算。
- **排除**:只排除 bin,依据只取声明和语法:源文件以 `#!` 开头,或者是 package.json `bin` 指向的 `lib/<path>.js` 对应的 `src/<path>.ts|.tsx`。bin 由进程直接执行,不会被其他模块导入。当前排除 webworker-packer 和 llm-mock-server 的两个 `src/bin.ts`,清单由 spec 锁定。
- **语料防护**:语料为空、没有 `.tsx`、或某个含 TS 的 `src/` 目录完全没进语料时,门禁报错。
- **注册**:`run-gates` 的共享卫生门禁,跑在 static lane 和 hygiene 聚合里。规则正文写在 [package-surface 防御模式](../../../../docs/defensive-patterns/package-surface.zh.md)。
## 考虑过的替代方案
**只靠 webworker packer 的拒绝来把关。** packer 只检查进入镜像的模块,镜像外的包仍可能引入顶层 await。
**用 lint 规则实现。** oxlint 没有现成规则能精确表达“模块顶层”。语法级脚本门禁更可控,也能用测试锁定每一种纳入和排除的形式。
**连 bin 一起禁止。** bin 由进程直接执行,不被导入,顶层 await 不影响任何模块的加载。
## 后果
每个可被导入的包源码都同步求值,包括从不进入 Preview 镜像的包;每个入口自己处理启动失败;voice-input-bundle 能进入 Preview 镜像。
这一取舍的代价:
- 第三方包和 `apps/` 不在范围内;页面引导脚本 `apps/web/src/preview.ts` 使用顶层 await。
- sensevoice 启动失败的诊断只有错误消息,不带堆栈。
## 测试
- [verify-no-top-level-await.spec.ts](../../../../scripts/verify-no-top-level-await.spec.ts) 锁定每一种纳入和排除的形式、语料防护和当前的排除清单;[run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 锁定门禁的注册。若上述三个入口的异步启动放在模块顶层,门禁报出 4 处(sensevoice 1 处、ssh 1 处、inspector 2 处)并失败。
- [worker-entry.spec.ts](../../../../packages/experimental/speech-to-text-sensevoice/tests/worker-entry.spec.ts) 启动 sensevoice 入口,锁定就绪前失败时退出码为 1、stdout 为空、stderr 给出原因且不带堆栈。
- [helper-boundaries.spec.ts](../../../../packages/ssh/ssh/tests/helper-boundaries.spec.ts) 启动 ssh helper 入口,[integration.host.spec.ts](../../../../packages/experimental/inspector/tests/integration.host.spec.ts) 经 `startInspector` 启动 inspector Worker;没有 spec 触发 inspector worker 入口的启动失败帧。
@@ -1,46 +0,0 @@
# Bilingual-pair consistency record for 2026-10-02-plugin-market-index-format.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 .agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.md
/agent-note-plugin-market-index-format:
en: 8da20a8601d7cb29
zh: 74368eed4d4b8e7c
/agent-note-plugin-market-index-format/problem:
en: ce5e4a362f558af6
zh: 29d93e0655b99217
/agent-note-plugin-market-index-format/proposal:
en: ed9148e95b2ad7c2
zh: 25a7add3c56fd681
/agent-note-plugin-market-index-format/proposal/example:
en: 1b1c960c966cf912
zh: 97be87cb14893c26
/agent-note-plugin-market-index-format/proposal/root-fields:
en: 4fac5a9b8261342d
zh: f9168a8a4209feab
/agent-note-plugin-market-index-format/proposal/plugin-fields:
en: 19d743bd04dc65b3
zh: 810a7f98c4fe8301
/agent-note-plugin-market-index-format/proposal/version-fields:
en: d6c0fa99e1cbb068
zh: 3d2a7b85299c679a
/agent-note-plugin-market-index-format/proposal/install-arguments:
en: c748cdc4d8ce349a
zh: 5740fa0d6106e637
/agent-note-plugin-market-index-format/proposal/dsh-compatibility:
en: 00fe507b9b5dfea7
zh: 55de6aaeb3865e84
/agent-note-plugin-market-index-format/proposal/generator-obligations:
en: 8e848a102fe9d86e
zh: feff226ef8321e00
/agent-note-plugin-market-index-format/proposal/reader-rules:
en: 92903cbe87d9c0ca
zh: 54af6cb33e9da252
/agent-note-plugin-market-index-format/alternatives-considered:
en: 3d7cf519bc8e0593
zh: 328dc0747836eaa8
/agent-note-plugin-market-index-format/acceptance-criteria:
en: 753abee6adf39812
zh: 1102bcfdd48f7e90
/agent-note-plugin-market-index-format/risks:
en: 41170d483b17df80
zh: 1bfd8f44e0f09036
@@ -1,12 +1,16 @@
---
name: dsh-official-plugin-artwork
description: Draw and review official DeepSeek Harness plugin icons, including experimental plugins. Use for package icon.svg assets and PluginArtwork glyphs; covers semantic references, proportions, lettering, transparency, package metadata, and designer review. Not for toolbar or action icons.
description: Draw and review official DeepSeek Harness plugin icons and optional feature previews, including experimental plugins. Use for package artwork and PluginArtwork glyphs; covers semantic references, proportions, lettering, transparency, localization, package metadata, and designer review. Not for toolbar or action icons.
---
# Official plugin artwork
Draw recognizable artwork that belongs beside the existing official icons. Official and official experimental plugins share this visual language; the UI label communicates experimental status. This guidance does not replace designer approval.
## Optional feature previews
Before drawing a larger image below a built-in bundle's description, read and follow [adding a built-in plugin feature preview](../../../docs/cookbook/adding-a-plugin-preview.md). It owns optionality, image count, design canvas, display size and position, language variants, asset limits, and acceptance. The guide also owns the embedded-raster exception for SVG previews; small icons remain geometry-only. The icon dimensions below do not apply to previews. Inspect the real feature, preserve its produced assets, and keep built-in previews in the Plugin Manager client assets directory and its internal bilingual map.
## Establish the subject
- Inspect the actual feature, its output, and its owning manifest before drawing. A badge-producing skill may need a tag, not a medal. Reuse designer-supplied assets; do not invent a brand mark or change product names, identifiers, or unrelated icons without approval.
+9
View File
@@ -572,6 +572,15 @@ jobs:
- name: Run blocking Windows builds
shell: pwsh
run: pnpm run check:ci:windows-blocking
- name: Parse Windows runner supervision scripts
shell: pwsh
run: |
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1')) {
$tokens = $null
$parseErrors = $null
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw ($parseErrors -join "`n") }
}
- name: Run Windows observational gates on built artifacts
id: observational
continue-on-error: true
+8 -2
View File
@@ -6,11 +6,11 @@ on:
workflow_dispatch:
inputs:
package_run_id:
description: 'Successful Package Desktop run ID'
description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包'
type: string
required: true
package_run_attempt:
description: 'Package attempt; blank selects latest, which must have succeeded'
description: '打包尝试次数:留空选择最新一次,且必须成功;失败时不会回退到旧包'
type: string
required: false
default: ''
@@ -31,6 +31,11 @@ jobs:
- uses: actions/[email protected]
with:
persist-credentials: false
- name: Reject debugging packages before dependency installation
id: preflight
env:
GH_TOKEN: ${{ github.token }}
run: node apps/desktop/scripts/preflight-desktop-publish.mjs
- uses: pnpm/[email protected]
- uses: actions/[email protected]
with:
@@ -41,6 +46,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
DESKTOP_PACKAGE_ATTEMPT: ${{ steps.preflight.outputs.attempt }}
run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts
prepare:
+20 -9
View File
@@ -4,40 +4,40 @@ on:
workflow_dispatch:
inputs:
preset:
description: 'Machine preset (configured on the packaging runner)'
description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境'
type: choice
options: [test, production]
default: test
required: true
skip_notarization:
description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates'
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
default: false
required: true
test_release_id:
description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。'
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
source_ref:
description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters'
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
update_channel:
description: 'Installer update channel; automatic updates stay on this channel'
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
type: choice
options: [nightly, latest]
default: nightly
required: true
version_mode:
description: 'default: production follows product, test auto; manual uses build_version'
description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填'
type: choice
options: [default, product, auto, manual]
default: default
required: true
build_version:
description: 'Manual version only: product version or its supported prerelease suffix'
description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀'
type: string
default: ''
required: false
@@ -74,9 +74,20 @@ jobs:
- name: Validate selected source and adapter support
run: >-
node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF;
if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*');
const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET;
if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset');
const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref);
if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*');
if(tag) {
const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;
if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version');
if(ref!=='dsh-v'+v) throw Error('Source tag must match product version');
const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim();
const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim();
if(tagged!==head) throw Error('Checkout does not match source tag');
} else {
const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim();
if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref');
}
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/[email protected]
with:
+22 -11
View File
@@ -4,41 +4,41 @@ on:
workflow_dispatch:
inputs:
preset:
description: 'Machine preset (configured on the packaging runner)'
description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境'
type: choice
options: [test, production]
default: test
required: true
signing:
description: 'unsigned: test installer; signed: use the configured EV hardware token'
description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)'
type: choice
options: [unsigned, signed]
default: unsigned
options: [signed, unsigned]
default: signed
required: true
test_release_id:
description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。'
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
source_ref:
description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters'
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
update_channel:
description: 'Installer update channel; automatic updates stay on this channel'
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
type: choice
options: [nightly, latest]
default: nightly
required: true
version_mode:
description: 'default: production follows product, test auto; manual uses build_version'
description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填'
type: choice
options: [default, product, auto, manual]
default: default
required: true
build_version:
description: 'Manual version only: product version or its supported prerelease suffix'
description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀'
type: string
default: ''
required: false
@@ -86,9 +86,20 @@ jobs:
- name: Validate selected source and adapter support
run: >-
node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF;
if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*');
const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET;
if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset');
const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref);
if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*');
if(tag) {
const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;
if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version');
if(ref!=='dsh-v'+v) throw Error('Source tag must match product version');
const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim();
const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim();
if(tagged!==head) throw Error('Checkout does not match source tag');
} else {
const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim();
if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref');
}
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/[email protected]
with:
+4 -1
View File
@@ -92,6 +92,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT |
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
| [`got`](https://github.com/sindresorhus/got) | MIT |
| [`hosted-git-info`](https://github.com/npm/hosted-git-info) | ISC |
| [`immer`](https://github.com/immerjs/immer) | MIT |
| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
@@ -110,6 +111,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
| [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
| [`minisearch`](https://github.com/lucaong/minisearch) | MIT |
| [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
@@ -129,6 +131,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 |
| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
| [`turndown`](https://github.com/mixmark-io/turndown) | MIT |
| [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 |
@@ -202,6 +205,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/hosted-git-info`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
@@ -252,7 +256,6 @@ External packages **directly declared** for development, tests, types, or toolin
| [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT |
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 |
| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
| [`vite`](https://github.com/vitejs/vite) | MIT |
+2 -2
View File
@@ -12,8 +12,8 @@
en: cc03e6b75206661b
zh: 99e41f4143198352
/deepseek-ai-dsh/profiles:
en: 79cad979b0f7895c
zh: b0f922a4b43accf8
en: 523477a093beb0c2
zh: f88ea21e5fbea969
/deepseek-ai-dsh/optional-overlays:
en: 3a40ddc44e55f79c
zh: 8d3949132e1ef93c
+1 -1
View File
@@ -36,7 +36,7 @@ dsh --help # the launcher's own help
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). `dsh-hmr`, when enabled in YAML, watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload. Without HMR, changes apply on restart. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections. CLI package commands inherit authentication variables and terminal descriptors, including interactive build approval; service calls retain their scrubbed environment and captured diagnostics.
Installation and profile startup enforce declared DSH peer ranges against the same runtime version shown by `dsh --version`. Incompatible plugins require an explicitly acknowledged exact-version exemption. The [plugin manager's compatibility reference](../../packages/boot/plugin-manager/README.md#version-compatibility-and-exemptions) documents `version-exemptions`, `allow-version`, `revoke-version`, persistence, and risks.
Installation and profile startup enforce the declared `engines.dsh` range, or DSH peer ranges when it is absent, against the same runtime version shown by `dsh --version`. Incompatible plugins require an explicitly acknowledged exact-version exemption. The [plugin manager's compatibility reference](../../packages/boot/plugin-manager/README.md#version-compatibility-and-exemptions) documents `version-exemptions`, `allow-version`, `revoke-version`, persistence, and risks.
The tree composes over an empty root:
- each bundle's patch in `dsh.profile.bundles` order
+1 -1
View File
@@ -36,7 +36,7 @@ dsh --help # the launcher's own help
profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。在 YAML 中启用的 `dsh-hmr` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层。未启用 HMR 时,更改在重启后生效。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。CLI 包操作继承认证环境和终端描述符,支持交互式构建批准;service 调用保留清理后的环境并捕获诊断。
安装和 profile 启动会按声明的 DSH peer 范围,检查与 `dsh --version` 显示值相同的运行时版本。不兼容插件需要用户明确确认精确版本豁免。[插件管理器的兼容性参考](../../packages/boot/plugin-manager/README.zh.md#version-compatibility-and-exemptions)说明 `version-exemptions`、`allow-version`、`revoke-version`、持久化规则与风险。
安装和 profile 启动会按声明的 `engines.dsh` 范围(未声明时按 DSH peer 范围),检查与 `dsh --version` 显示值相同的运行时版本。不兼容插件需要用户明确确认精确版本豁免。[插件管理器的兼容性参考](../../packages/boot/plugin-manager/README.zh.md#version-compatibility-and-exemptions)说明 `version-exemptions`、`allow-version`、`revoke-version`、持久化规则与风险。
配置树以空根为起点,依次叠加以下配置层:
- `dsh.profile.bundles` 中各组合包的 patch
+3
View File
@@ -180,6 +180,8 @@ flowchart LR
cfg --> plugin_dsh_base_web
plugin_dsh_base_web_search_deepseek["web-search-deepseek<br/>@deepseek-ai/dsh-web-search-deepseek"]
cfg --> plugin_dsh_base_web_search_deepseek
plugin_dsh_base_web_deepseek_api["web-deepseek-api<br/>@deepseek-ai/dsh-web-deepseek-api"]
cfg --> plugin_dsh_base_web_deepseek_api
plugin_dsh_base_web_fetch_http["web-fetch-http<br/>@deepseek-ai/dsh-web-fetch-http"]
cfg --> plugin_dsh_base_web_fetch_http
plugin_dsh_base_tool_web["tool-web<br/>@deepseek-ai/dsh-tool-web"]
@@ -288,6 +290,7 @@ flowchart LR
| `repeat-tool-reminder` | `@deepseek-ai/dsh-repeat-tool-reminder` |
| `web` | `@deepseek-ai/dsh-web` |
| `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` |
| `web-deepseek-api` | `@deepseek-ai/dsh-web-deepseek-api` |
| `web-fetch-http` | `@deepseek-ai/dsh-web-fetch-http` |
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
| `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` |
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.2.1-alpha.3",
"version": "0.2.1-rc.3",
"publishConfig": {
"access": "public"
},
@@ -104,7 +104,8 @@
"@deepseek-ai/dsh-experimental-session-titles-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-session-search": "workspace:*",
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*"
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/dsh-skill-badge": "workspace:*",
+6
View File
@@ -443,6 +443,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}, SPAWN_TIMEOUT_MS + 30_000)
it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => {
// On the official endpoint the route requests its model listing with this key;
// the loopback mock keeps the run offline and records any model call.
const server = await startMockLlmServer({ sequence: ['success'] })
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
const pluginPath = join(home, 'plugin #100%.mjs')
const marker = join(home, 'plugin-loaded')
@@ -465,6 +468,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
DEEPSEEK_BASE_URL: server.baseURL,
},
extendEnv: false,
})
@@ -504,10 +508,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.signal, stderr).toBeUndefined()
expect(result.exitCode, stderr).toBe(0)
expect(stderr).toBe('')
expect(server.requests).toEqual([])
expect(readFileSync(marker, 'utf8')).toBe('loaded')
} finally {
child.kill('SIGKILL')
await child
await server.close()
rmSync(home, { recursive: true, force: true })
}
}, SPAWN_TIMEOUT_MS + 30_000)
+1 -1
View File
@@ -87,7 +87,7 @@ it('names the exact grant command for each package a compatibility check refused
const { stderr } = fixture()
vi.mocked(runPluginCommand).mockResolvedValue({
exitCode: 1, output: '', truncated: false, logPath: '/profile/log',
incompatible: [{ name: '@example/plugin', version: '1.2.3', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^9.0.0' } }],
incompatible: [{ name: '@example/plugin', version: '1.2.3', runtimeVersion: '0.1.0', requirements: { '@deepseek-ai/dsh': '^9.0.0' } }],
})
expect(await runPlugin('test', ['add', '@example/plugin'])).toBe(1)
expect(stderr.mock.calls.map(call => call[0])).toEqual([
@@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import { lastAssistantStreamChunk, type RetryPolicyConfig, type TokenUsage } from '@deepseek-ai/dsh-llm'
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { describe, expect, it, onTestFinished } from 'vitest'
@@ -22,6 +22,8 @@ interface ObservedRequest {
step: number
body: {
system?: string
max_tokens: number
thinking: { type: string }
messages: { role: string; content: { type: string; text?: string; tool?: { name: string } }[] }[]
tools?: { name: string; defer_loading?: true }[]
}
@@ -70,7 +72,7 @@ function registryChanges(previous: ObservedRequest, request: ObservedRequest): {
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with real DeepSeek', () => {
it.each([false, true])('keeps the preceding conversation cached after a tool addition (prompt update: %s)', { retry: 0 }, async (updatePrompt) => {
it.each([false, true])('keeps the preceding conversation cached after a tool addition (prompt update: %s)', async (updatePrompt) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-tool-cache-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
const sampleLabel = `CACHE_SAMPLE_${randomUUID()}`
@@ -80,7 +82,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea
await writeFile(patch, JSON.stringify([
{ id: 'llm-deepseek', config: {
apiKeyEnv: 'DEEPSEEK_API_KEY', thinking: 'disabled', maxTokens: 1024,
retryPolicy: { maxRetries: 0 },
retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig,
models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }],
} },
{ id: 'tools', config: { mode: 'native' } },
@@ -101,13 +103,19 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea
const run = async (prompt: string) => {
const result = await session.run(prompt)
events.push(...result.events)
const failures = result.events.filter(event => event.type === 'assistant/attempt').map(event => ({
turn: event.data.turn,
step: event.data.step,
reason: lastAssistantStreamChunk(event.data.stream, 'finish')?.reason,
}))
expect(failures, `Provider request failures: ${JSON.stringify(failures)}`).toEqual([])
return result.finalResponse
}
await run('Do not call tools. Reply READY.')
expect(await run('Do not call tools. Reply READY.')).toContain('READY')
// Unique user history makes hits on shared profile instructions insufficient.
const records = Array.from({ length: 96 }, (_, index) => `record-${index}: ${randomUUID()}`).join('\n')
await run(`Keep these reference records. Do not call tools. Reply READY.\n${records}`)
await run('Keep the reference records. Do not call tools. Reply READY.')
expect(await run(`Keep these reference records. Do not call tools. Reply READY.\n${records}`)).toContain('READY')
expect(await run('Keep the reference records. Do not call tools. Reply READY.')).toContain('READY')
expect(await run(`Use cache_tool_control with action add. After it returns, call ${sampleTool} exactly once and report the generated sample label. This is synthetic example data for this run. Call these separately in order.`)).toContain(sampleLabel)
expectCalledBefore(await recordedCalls(callsPath), 'add', 'sample')
expect(await run('Use cache_tool_control with action remove. Whether it removes the sample tool or reports that it is not registered, reply REMOVED and do not call the sample tool.')).toContain('REMOVED')
@@ -115,7 +123,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea
expectCalledBefore(calls, 'sample', 'remove')
const requests = (await readFile(evidencePath, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as ObservedRequest)
expect(events.filter(event => event.type === 'assistant/attempt')).toHaveLength(0)
expect(requests).toHaveLength(events.filter(event => event.type === 'assistant/message').length)
const [initial, history, warm] = requests
const addedIndex = requests.findIndex(request => request.stage === 'added')
@@ -123,6 +130,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea
const added = requests[addedIndex]
const removed = requests.find(request => request.stage === 'removed')
if (!initial || !history || !warm || !before || !added || !removed) throw new Error('Missing cache transition requests')
expect(initial.body).toMatchObject({ max_tokens: 1024, thinking: { type: 'disabled' } })
const historyInput = totalInput(usageFor(history, events))
const precedingInput = totalInput(usageFor(before, events))
const cached = usageFor(added, events).cacheReadTokens ?? 0
@@ -3,9 +3,9 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
import { zstdDecompressSync } from 'node:zlib'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import { scanZstdFrames } from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.js'
import { execa } from 'execa'
import { describe, expect, it, onTestFinished } from 'vitest'
import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
@@ -15,7 +15,6 @@ const launch = resolveExampleLaunch({
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)),
mode: 'lib',
})
const decompress = promisify(zstdDecompress)
/** Frame one text or tool response from the local Messages endpoint. */
function messagesResponse(content: Record<string, unknown>, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string {
@@ -187,11 +186,30 @@ describe('Python SDK dsh profile keyless smoke', () => {
expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
const sessionsRoot = join(root, '.dsh', 'sessions')
const files = await readdir(sessionsRoot, { recursive: true })
const log = files.find(file => file.endsWith('.jsonl.zstd'))
// Session logs are JSONL across one zstd frame per batch, header frame
// first. Decompress frames explicitly: a single call over concatenated
// frames returned only the first frame on Node 24.19 and returns every
// frame on 24.22, so neither behavior may back the assertion.
const logs: { compressed: Buffer; header: Record<string, unknown> }[] = []
for (const file of files.filter(file => file.endsWith('.jsonl.zstd'))) {
const compressed = await readFile(join(sessionsRoot, file))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
const first = scanZstdFrames(compressed).frames[0]
if (first === undefined) continue
const headerLine = zstdDecompressSync(compressed.subarray(first.start, first.end)).toString().trim().split('\n')[0]
if (headerLine === undefined) continue
logs.push({ compressed, header: JSON.parse(headerLine) as Record<string, unknown> })
}
const log = logs.find(entry => entry.header.type === 'session' && entry.header.id === 'main')
expect(log).toBeDefined()
const compressed = await readFile(join(sessionsRoot, log!))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
const { frames, tornStart } = scanZstdFrames(log!.compressed)
expect(tornStart).toBeUndefined()
const records = frames
.flatMap(({ start, end }) => zstdDecompressSync(log!.compressed.subarray(start, end)).toString().trim().split('\n'))
.map(line => JSON.parse(line) as Record<string, unknown>)
expect(records[0]).toMatchObject({ type: 'session', id: 'main' })
expect(records.length).toBeGreaterThan(1)
expect(records.some(record => record.type === 'turn/end')).toBe(true)
} finally {
// No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
child.kill('SIGKILL')
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
"version": "0.2.1-alpha.3",
"version": "0.2.1-rc.3",
"private": true,
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -21,7 +21,7 @@ export interface Config {
}
/**
* Enable offline Office authoring and structural checks in the Desktop profile.
* Enable offline Office authoring and LibreOffice Kit operations in the Desktop profile.
* @param ctx - Profile scope; child plugins declare their own service requirements.
* @param config - Bundled payload source and Harness-home installation root.
*/
+25
View File
@@ -0,0 +1,25 @@
// Directory replacement failures retain the original Windows error and the independent restoration outcome.
#pragma once
#include "extract-report.h"
namespace directory_report {
// The recovery path stays complete in the dialog even when its report excerpt is truncated.
inline std::wstring Content(DWORD error, LPCWSTR hint, DWORD restoreError, LPCWSTR backup) {
std::wstring content = std::to_wstring(error) + L": " + extract_report::Win32Message(error) + L"\r\n\r\n" + hint;
if (restoreError) content += L"\r\n\r\n" + std::wstring(backup);
return content;
}
inline std::wstring Compose(DWORD error, LPCWSTR source, LPCWSTR destination, DWORD restoreError, LPCWSTR backup, const std::wstring& attempts) {
const std::wstring operation = *destination ? L"MoveFileExW" : L"RemoveDirectoryW";
std::wstring report = L"Operation: " + operation + L"\r\nWindows error: " + std::to_wstring(error)
+ L" (" + extract_report::Win32Message(error) + L")\r\nSource: " + source
+ L"\r\nDestination: " + destination + L"\r\nRestoration error: " + std::to_wstring(restoreError);
if (restoreError) report += L" (" + extract_report::Win32Message(restoreError) + L")\r\nPreserved backup: " + backup;
report += L"\r\nTime: " + extract_report::Timestamp() + L"\r\nWindows: " + extract_report::WindowsVersion()
+ L"\r\nAttempts:\r\n" + attempts;
return report;
}
}
+14 -10
View File
@@ -130,16 +130,8 @@ inline std::wstring Compose(int code, const std::wstring& archive, const std::ws
return report;
}
// The expanded panel adds 7-Zip's own lines beneath the result; long lines are cut and a trailing note counts what the
// saved report still holds. Header fields already appear in the report file and clipboard.
inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) {
std::vector<std::wstring> lines;
bool inOutput = false;
for (const std::wstring& line : Lines(Trim(report))) {
if (inOutput) lines.push_back(line);
else if (line.rfind(L"Result: ", 0) == 0) lines.push_back(line);
else if (line == kOutputMarker) inOutput = true;
}
// Expanded native dialogs bound both line count and line length; files and clipboard keep the complete report.
inline std::wstring BoundedExcerpt(const std::vector<std::wstring>& lines, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) {
std::wstring excerpt;
size_t shown = 0;
for (const std::wstring& line : lines) {
@@ -154,6 +146,18 @@ inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcer
return excerpt;
}
// The extraction panel selects 7-Zip's own lines beneath the result; metadata remains in the saved report.
inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) {
std::vector<std::wstring> lines;
bool inOutput = false;
for (const std::wstring& line : Lines(Trim(report))) {
if (inOutput) lines.push_back(line);
else if (line.rfind(L"Result: ", 0) == 0) lines.push_back(line);
else if (line == kOutputMarker) inOutput = true;
}
return BoundedExcerpt(lines, maxLines, maxChars);
}
// A cut inside a multi-byte sequence would make the whole UTF-8 attempt fail; drop the incomplete tail first.
inline void TrimPartialUtf8(std::string& bytes) {
size_t trailing = 0;
+6
View File
@@ -29,6 +29,12 @@ LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close
LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。"
LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again."
LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。"
LangString INSTALLER_DIRECTORY_FAILED ${LANG_ENGLISH} "The installation directory could not be replaced."
LangString INSTALLER_DIRECTORY_FAILED ${LANG_SIMPCHINESE} "无法替换安装目录。"
LangString INSTALLER_DIRECTORY_HINT ${LANG_ENGLISH} "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists."
LangString INSTALLER_DIRECTORY_HINT ${LANG_SIMPCHINESE} "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。"
LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_ENGLISH} "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again."
LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_SIMPCHINESE} "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。"
LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported."
LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。"
LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder."
+24
View File
@@ -13,10 +13,34 @@
#include "progress.h"
#include "extract.h"
#include "extract-report.h"
#include "directory-report.h"
#include "uninstall-data.h"
using namespace Gdiplus;
// Returns whether the UTF-8 report was saved; failure still presents the original error unless silent.
extern "C" __declspec(dllexport) int __cdecl InstallerReportDirectoryFailure(
HWND parent, DWORD error, LPCWSTR source, LPCWSTR destination, DWORD restoreError, LPCWSTR backup,
LPCWSTR log, LPCWSTR reportPath, int show, LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy,
LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied) {
try {
const std::wstring report = directory_report::Compose(error, source, destination, restoreError, backup, extract_report::ReadOutput(log));
const bool saved = extract_report::WriteUtf8(reportPath, report);
if (show) {
std::wstring footer = saved ? savedFormat : unsaved;
const auto placeholder = footer.find(L"%s");
if (saved && placeholder != std::wstring::npos) footer.replace(placeholder, 2, reportPath);
const extract_report::DialogStrings strings{title, heading, copy, expand, collapse, footer.c_str(), copied};
const std::wstring content = directory_report::Content(error, hint, restoreError, backup);
extract_report::Show(parent, content, extract_report::BoundedExcerpt(extract_report::Lines(extract_report::Trim(report))), report, strings);
}
return saved ? 1 : 0;
} catch (const std::bad_alloc&) {
if (show) MessageBoxW(parent, hint, heading, MB_OK | MB_ICONEXCLAMATION);
return 0;
}
}
// Saves the extraction failure report and, unless silent, presents its headline with a copy action.
// Returns 1 when the report file was written and 0 otherwise; the dialog outcome never fails the caller.
extern "C" __declspec(dllexport) int __cdecl InstallerReportExtractFailure(
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
"version": "0.2.1-alpha.3",
"version": "0.2.1-rc.3",
"private": true,
"license": "MIT",
"type": "module",
+4 -4
View File
@@ -24,8 +24,8 @@
en: f5dc36baf414ecc5
zh: 8984741b356ac1d3
/desktop-packaging/windows-actions-packaging:
en: e3f51e7130a0fe05
zh: 70fd6a40d5899871
en: e1b101593f6f755e
zh: 121f225de399de1e
/desktop-packaging/macos-self-hosted-packaging:
en: a3af9523fdc60036
zh: 5e18d4740e9b7f57
@@ -45,8 +45,8 @@
en: b42f103358d0dfd7
zh: a534164ad9eb00da
/desktop-packaging/runtime-file-selection-and-installation-checks:
en: 06d5c666a3e7f2e4
zh: c1d4a55cc29e11e3
en: bfd89e1d139c5729
zh: 5d6cced5462ba13e
/desktop-packaging/desktop-runtime-preparation:
en: a7aa3066e649ffe8
zh: 4334637910058c09
+5 -1
View File
@@ -103,7 +103,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
## Windows Actions packaging
The `Package Desktop · Windows` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
For supervised runner startup and recovery, follow [Windows runner machine setup](../windows-signing/README.md#machine-setup).
The `Package Desktop · Windows` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts.
@@ -244,6 +246,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis
The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.
Directory renames, empty-destination removal, and restoration retry Windows errors 5, 32, and 33 up to ten attempts, with 250 ms increasing delays (11.25 seconds of total waiting per operation). Other errors stop immediately. A replacement failure reports the Windows error, affected paths, and restoration outcome in `directory-failure-<timestamp>.log` beside extraction reports. If restoration fails, the installer removes unlocked staging files and preserves the backup. After reporting that failure, exit cleanup does not retry restoration. Keep the backup path named in the report and contact support before reinstalling. Silent installs save the same diagnostic without a dialog.
When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\<package-derived name>-updater\installer-logs\extract-failure-<timestamp>.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness-<version>-win-x64-unsigned.exe` so they cannot be mistaken for release artifacts.
Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract.
+5 -1
View File
@@ -103,7 +103,9 @@ macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../
## Windows Actions 打包
`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-signing/README.zh.md#machine-setup)。
`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。
@@ -244,6 +246,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis
Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。
目录改名、空目标目录删除和旧版恢复遇到 Windows 错误 5、32 或 33 时最多尝试十次,每次等待递增 250 毫秒(每个操作合计等待 11.25 秒)。其他错误立即停止。替换失败时,安装器将 Windows 错误、相关路径和恢复结果写入解压报告旁的 `directory-failure-<时间戳>.log`。如果恢复失败,安装器会清理未被占用的暂存文件并保留备份。上报此失败后,退出清理不再重试恢复。请保留报告所列的备份目录,并在重新安装前联系支持人员。静默安装保存相同的诊断,不显示弹窗。
解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。
Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
+6 -6
View File
@@ -15,14 +15,14 @@
en: 1b6a4d7146879eec
zh: f2520fc412cd2d2a
/desktop-ci-publication/machine-setup:
en: 990ff7e56dd6005a
zh: 18f9dc23c520dc8c
en: f80388c2b1ffef4c
zh: f242ab2316bf38a1
/desktop-ci-publication/package-and-publish:
en: c17307a96efdde3a
zh: 53ba328b149ed407
en: 0a26537845e89dda
zh: 307378bb65e24650
/desktop-ci-publication/recovery-and-records:
en: e92bfad06162443e
zh: 6c3e3d8ece673217
en: 2e1e6b16d0d6bd41
zh: 6ff4ad5a8f1f57b4
/desktop-ci-publication/restore-a-channel:
en: d82e2ccf70cce397
zh: d93dcb77cc4b463e
+6 -4
View File
@@ -23,7 +23,7 @@ Publish a completed package from its original machine without downloading its in
Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases/<run-id>/<attempt>/`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published.
Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Bucket versioning must be disabled for immutable binary writes.
Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration.
Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other.
@@ -35,11 +35,13 @@ Environment approval gates this workflow, not arbitrary code running under the s
## Package and publish
Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts `master` or `dsh-v*` matching the selected product version; the selected source must contain these CI adapters, so older tags fail before dependency installation. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits.
For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes.
Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits.
Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication.
Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes an approval summary with version, channel, platform, deployment, source ref/SHA, package run/attempt, object keys and proposed tag. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v<version>` at the recorded packaging commit; an existing tag must name the same commit.
Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v<version>` at the recorded packaging commit; an existing tag must name the same commit.
<a id="recovery-and-records"></a>
@@ -47,7 +49,7 @@ Before uploading, the publisher hashes all retained files, checks version and de
After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit.
The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; raw provider responses are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate.
The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate.
If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically.
+6 -4
View File
@@ -23,7 +23,7 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安
保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases/<run-id>/<attempt>/`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。
在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。不可覆盖的二进制写入要求 bucket 版本控制关闭。
在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。
将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。
@@ -35,11 +35,13 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
## 打包与发布
选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。`source_ref` 接受 `master` 或与所选产品版本一致的 `dsh-v*`;源码必须包含这些 CI 适配脚本,因此旧 tag 会在安装依赖前失败。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。
发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。
选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。
打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。
上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成审批摘要,列出版本、通道、平台、环境、源码 ref/SHA、package run/attempt、对象路径及拟创建的 tag。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v<version>`;已有 tag 必须指向同一提交。
上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v<version>`;已有 tag 必须指向同一提交。
<a id="recovery-and-records"></a>
@@ -47,7 +49,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。
同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断,不保存原始服务响应。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。
同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。
频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。
@@ -5,13 +5,40 @@ import { DesktopPublishError } from './desktop-publish.ts'
const publicCodes = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch',
'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT'])
/** COS operation failure retaining its cause for safe diagnostics; a failed write may have an unknown remote outcome. */
export class DesktopCosOperationError extends Error {
constructor(readonly operation: 'GetObject' | 'PutObject', cause: unknown) {
const failure = desktopPublicationFailure(cause, operation)
const message = failure.code === 'AccessDenied'
? `AccessDenied. Check name/cos:${operation} permission and explicit deny rules for the selected bucket/object. Publication stopped; reconcile remote state before retrying.`
: failure.message
super(`COS ${operation}: ${message}`, { cause })
this.name = 'DesktopCosOperationError'
}
}
/**
* Keep actionable local validation details without recording SDK response bodies or credentials.
* @param error Failure from validation, filesystem, subprocess or remote transport.
* @param phase Operation stage known by the entrypoint.
* @returns Credential-free diagnostic suitable for stderr and an audit event.
*/
export function desktopPublicationFailure(error: unknown, phase: string): { phase: string; code: string; message: string } {
export function desktopPublicationFailure(error: unknown, phase: string): {
phase: string
code: string
message: string
operation?: 'GetObject' | 'PutObject'
requestId?: string
httpStatus?: number
} {
if (error instanceof DesktopCosOperationError) {
const cause = typeof error.cause === 'object' && error.cause !== null ? error.cause : {}
const requestId = 'RequestId' in cause ? cause.RequestId : undefined
const status = 'statusCode' in cause ? cause.statusCode : undefined
return { ...desktopPublicationFailure(error.cause, phase), operation: error.operation, message: error.message,
...(typeof requestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(requestId) ? { requestId } : {}),
...(typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}) }
}
if (error instanceof DesktopPublishError) return { phase, code: error.code, message: error.message }
if (error instanceof ZodError) return { phase, code: 'invalid-data',
message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` }
@@ -0,0 +1,71 @@
/** Render the verified publication plan for GitHub's native deployment reviewers. */
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import type { DesktopRetainedSource } from './desktop-release-store.ts'
import type { DesktopUploadPlan } from './desktop-upload-plan.ts'
function text(value: string): string {
return value.replace(/[&<>"'|`\r\n\\*_\[\]()!~:]/gu, character => `&#${character.charCodeAt(0)};`)
}
function code(value: string): string { return `<code>${text(value)}</code>` }
function row(label: string, value: string): string { return `| ${label} | ${value} |` }
function link(url: string, label = url): string { return `<a href="${text(url)}">${text(label)}</a>` }
/**
* Render Chinese approval details after package, destination and signature verification succeeds.
* @param source Verified retained packaging identity; a test plan requires its validated release ID.
* @param plan Validated upload plan; YAML contents are the bytes that will be uploaded.
* @param approval Digest bound to the approved manifest and upload plan.
* File SHA-256 hex supports `shasum -a 256` on downloads; upload reconciliation separately uses SHA-512 base64.
* @returns GitHub-flavored Markdown with final URLs and upload-byte fingerprints, without credentials or local paths.
*/
export async function desktopPublicationSummary(
source: DesktopRetainedSource, plan: DesktopUploadPlan, approval: string,
): Promise<string> {
const origin = new URL(plan.publicUrl).origin
const url = (key: string) => `${origin}/${key.split('/').map(encodeURIComponent).join('/')}`
const files = []
for (const file of plan.artifacts) {
const hash = createHash('sha256')
let size = 0
const bytes = file.contents === undefined ? createReadStream(file.path) : [Buffer.from(file.contents)]
for await (const chunk of bytes) { size += chunk.length; hash.update(chunk) }
files.push(row(link(url(file.key), file.filename), `${size} 字节 | ${code(hash.digest('hex'))}`))
}
const installerExtension = plan.target === 'win-x64' ? '.exe' : '.dmg'
const installers = plan.artifacts.filter(file => !file.channelMetadata && file.filename.endsWith(installerExtension))
const feeds = plan.artifacts.filter(file => file.channelMetadata)
const runUrl = `https://github.com/${source.repository}/actions/runs/${source.runId}/attempts/${source.attempt}`
const environment = plan.environment === 'test' ? 'desktop-test' : 'desktop-production'
const tag = plan.environment === 'production' ? code(`desktop-v${plan.version}`) : '不创建(test 环境)'
return [
'', '## 发布前确认', '',
`核对以下信息后,点击 **Review deployments**,选择 ${code(environment)} 并点击 **Approve and deploy**;信息有误请点击 **Reject**。`, '',
'| 项目 | 发布信息 |', '| --- | --- |',
row('发布环境', code(plan.environment)),
row('版本号', code(plan.version)),
row('升级通道', code(source.channel)),
row('平台', code(plan.target)),
...(plan.environment === 'test' ? [row('测试 release ID', `${code(source.testReleaseId!)}<br>测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。`)] : []),
row('下载地址预览', `${installers.map(file => link(url(file.key))).join('<br>')}<br>发布后的目标地址,审批前不保证可下载。`),
row('升级 YAML', `${feeds.map(file => link(url(file.key))).join('<br>')}<br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。`),
row('打包来源', `${link(runUrl, `查看打包运行 #${source.runId} · 第 ${source.attempt} 次尝试`)}<br>源码:${code(`${source.sourceRef} @ ${source.commit}`)}<br>打包机器:${code(source.runnerName)}`),
row('签名与公证', plan.target === 'win-x64' ? 'EXE 签名校验通过' : 'DMG 校验通过<br>ZIP 内应用校验通过'),
row('Git tag', tag), '', '### 批准后将执行', '',
'1. 重新校验产物与发布目标;若与审批摘要不一致则停止。',
'2. 上传缺少的文件,跳过内容一致的已有文件;发现内容冲突则停止。',
'3. 更新上述升级 YAML,并保存发布记录。',
...(plan.environment === 'production' ? ['4. 为实际打包提交创建上述 Git tag;若 tag 创建失败,记录“已发布,待补 tag”。'] : []), '',
...(plan.environment === 'test' ? ['**本次只发布 test,不修改 production。**', ''] : []),
'<details>', '<summary>技术详情与文件清单</summary>', '',
`COS 存储桶:${code(plan.bucket)}`, '',
...(plan.environment === 'test' ? [
'release ID 取自原打包产物清单。打包时优先使用表单 `test_release_id`;留空则使用机器 `presets/test.env` 中的 `DOWNLOAD_TEST_RELEASE_ID`。发布时不重新选择。', '',
] : []),
`审批摘要 SHA-256:${code(approval)}`, '',
`共 ${plan.artifacts.length} 个文件;大小及 SHA-256 对应实际上传字节,包括更新后的 YAML 内容。`, '',
'| 文件 | 大小 | SHA-256 |', '| --- | --- | --- |', ...files, '', '</details>', '',
'若仓库为公开仓库,此摘要中的下载路径及 test 发布的 release ID 也会公开。随机前缀不能替代访问鉴权。', '',
].join('\n')
}
+5 -8
View File
@@ -7,6 +7,7 @@ import { createDesktopCos, DESKTOP_COS_REGION, type DesktopCosCredentials } from
import { cosOperation } from './cos-operation.ts'
import type { DesktopUploadPlan } from './desktop-upload-plan.ts'
import { desktopArtifactDigest, type DesktopPublishTransport } from './desktop-publish.ts'
import { DesktopCosOperationError } from './desktop-publication-failure.ts'
function missing(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'NoSuchKey'
@@ -14,6 +15,7 @@ function missing(error: unknown): boolean {
/**
* Create a bounded COS transport restricted to the validated plan's object keys.
* Administrators must keep bucket versioning absent; publication does not query bucket configuration.
* @param plan Destination and exact object inventory; arbitrary keys are rejected.
* @param credentials Credentials for this deployment only.
* @param createClient Client factory; absent uses the shared production client, tests supply a loopback client.
@@ -51,7 +53,7 @@ export function desktopPublishCos(
return { size, sha512: sha512.digest('base64'), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) }
} catch (error) {
if (missing(error)) return null
throw error
throw new DesktopCosOperationError('GetObject', error)
} finally { output.destroy() }
},
async put(artifact, digest, forbidOverwrite) {
@@ -62,13 +64,6 @@ export function desktopPublishCos(
const actual = await desktopArtifactDigest(artifact)
if (actual.size !== digest.size || actual.sha512 !== digest.sha512) throw new Error('desktop publish: local input changed')
const cos = client()
// COS overwrite prevention requires versioning to be absent, not enabled or suspended.
if (forbidOverwrite) {
const state = await cosOperation(cos, 30_000, () => cos.getBucketVersioning({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION }))
if (state.statusCode !== 200 || state.VersioningConfiguration.Status !== undefined) {
throw new Error('desktop publish: immutable objects require bucket versioning to be disabled')
}
}
const md5 = createHash('md5')
const bytes = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)]
for await (const chunk of bytes) md5.update(chunk)
@@ -78,6 +73,8 @@ export function desktopPublishCos(
...address, Body: body, ContentLength: digest.size, ContentType: artifact.contentType,
Headers: { 'Content-MD5': md5.digest('base64'), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) },
}))
} catch (error) {
throw new DesktopCosOperationError('PutObject', error)
} finally { body.destroy() }
},
}
@@ -0,0 +1,7 @@
/** Explain why retained debugging packages cannot enter either publication environment. */
/**
* Return rebuilding instructions for debugging packages.
* @param qualification Recorded package qualification.
* @returns Diagnostic, or undefined for a non-debugging qualification.
*/
export function desktopPublishQualificationError(qualification: string): string | undefined
@@ -0,0 +1,15 @@
/** Explain why retained debugging packages cannot enter either publication environment. */
/**
* Return rebuilding instructions for debugging packages.
* @param {string} qualification Recorded package qualification.
* @returns {string | undefined} Diagnostic, or undefined for a non-debugging qualification.
*/
export function desktopPublishQualificationError(qualification) {
const remedy = qualification === 'unsigned'
? '该包未签名(unsigned)。请重新运行 Windows 打包并选择 signing=signed。'
: qualification === 'unnotarized'
? '该包跳过了公证(unnotarized)。请重新运行 macOS 打包,取消勾选“跳过公证”,完成公证和 stapling。'
: undefined
return remedy && `desktop publish: ${remedy}当前产物仅限内部调试,不能发布到 test 或 production,也不能用于自动更新。打包成功后使用新的 run ID / attempt 发布;重跑当前发布任务无效。`
}
@@ -1,5 +1,6 @@
/** Validate a completed packaging attempt and route publication back to its original runner. */
import { z } from 'zod'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
const positive = z.number().int().positive()
const runSchema = z.object({
@@ -9,11 +10,12 @@ const runSchema = z.object({
})
const requestSchema = z.object({ source: z.object({
repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u),
workflowCommit: z.string(), sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u),
workflowCommit: z.string(), sourceRef: z.string().min(1),
channel: z.enum(['nightly', 'latest']),
runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']),
qualification: z.enum(['signed', 'notarized']),
}) })
qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']),
}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef),
'Production source must be master or dsh-v*') })
const jobsSchema = z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable() }))
const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) }))
@@ -44,6 +46,8 @@ export function resolveDesktopPublishRoute(
): DesktopPublishRoute {
const run = runSchema.parse(runValue)
const { source } = requestSchema.parse(requestValue)
const diagnostic = desktopPublishQualificationError(source.qualification)
if (diagnostic) throw new Error(diagnostic)
const jobs = jobsSchema.parse(jobsValue)
const routes = routesSchema.parse(routesValue)
const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml'
@@ -13,7 +13,7 @@ const sourceSchema = z.strictObject({
attempt: positiveId,
commit: z.string().regex(/^[a-f0-9]{40}$/u),
workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u),
sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u),
sourceRef: z.string().min(1),
channel: z.enum(['nightly', 'latest']),
runnerName: z.string().min(1),
// Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms.
@@ -25,7 +25,8 @@ const sourceSchema = z.strictObject({
// Older retained manifests carry this flag; manual publication does not depend on it.
publishRequested: z.boolean().optional(),
qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']),
})
}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef),
'Production source must be master or dsh-v*')
const manifestSchema = z.strictObject({
schemaVersion: z.literal(1),
source: sourceSchema,
+97 -8
View File
@@ -5,6 +5,71 @@ Var dshNewDirectory
Var dshOldDirectory
Var dshOldMoved
Var dshNewMoved
Var dshDirectoryError
Var dshDirectorySource
Var dshDirectoryDestination
Var dshRestoreError
Var dshDirectoryLogDirectory
Var dshRecoveryStopped
; MoveFileEx preserves the destination and exposes the Windows error before rollback changes it.
; Open files inside a directory can make its rename return access denied (5).
!macro dshDirectoryOperation SOURCE DESTINATION RESULT PHASE
Push $0
Push $1
Push $2
StrCpy $0 0
${Do}
${If} "${DESTINATION}" == ""
System::Call 'kernel32::RemoveDirectoryW(w "${SOURCE}") i.r1 ?e'
${Else}
System::Call 'kernel32::MoveFileExW(w "${SOURCE}", w "${DESTINATION}", i 0) i.r1 ?e'
${EndIf}
Pop ${RESULT}
${If} "${DESTINATION}" == ""
${If} ${RESULT} == 2
${OrIf} ${RESULT} == 3
StrCpy $1 1
${EndIf}
${EndIf}
${If} $1 != 0
StrCpy ${RESULT} 0
${ExitDo}
${EndIf}
ClearErrors
FileOpen $2 "$PLUGINSDIR\directory-operations.log" a
${IfNot} ${Errors}
FileSeek $2 0 END
FileWrite $2 "${PHASE}: Windows error: ${RESULT}$\r$\n"
FileClose $2
${EndIf}
${If} ${RESULT} != 5
${AndIf} ${RESULT} != 32
${AndIf} ${RESULT} != 33
${ExitDo}
${EndIf}
IntOp $0 $0 + 1
${If} $0 >= 10
${ExitDo}
${EndIf}
IntOp $1 $0 * 250
Sleep $1
${Loop}
Pop $2
Pop $1
Pop $0
!macroend
!macro dshReportDirectoryFailure
${If} $dshRestoreError != 0
StrCpy $dshRecoveryStopped "1"
${EndIf}
!ifmacrodef customInstallerDirectoryFailed
!insertmacro customInstallerDirectoryFailed
!else
DetailPrint "Directory operation failed ($dshDirectoryError): $dshDirectorySource -> $dshDirectoryDestination; restore error: $dshRestoreError"
!endif
!macroend
!macro dshExtractPayload FILE
!ifmacrodef customInstallerExtract
@@ -28,6 +93,10 @@ Var dshNewMoved
!macroend
!macro dshStageApplication
!ifdef DSH_INSTALLER_LOG_DIR
; electron-builder defines the cache name after including our function bodies, before this section expands.
StrCpy $dshDirectoryLogDirectory "${DSH_INSTALLER_LOG_DIR}"
!endif
StrCpy $dshFinalDirectory $INSTDIR
System::Call 'ole32::CoCreateGuid(g .r0) i .r1'
${If} $1 != 0
@@ -38,6 +107,9 @@ Var dshNewMoved
StrCpy $dshOldDirectory "$INSTDIR.old-$0"
StrCpy $dshOldMoved ""
StrCpy $dshNewMoved ""
StrCpy $dshDirectoryError 0
StrCpy $dshRestoreError 0
StrCpy $dshRecoveryStopped ""
ClearErrors
CreateDirectory $dshNewDirectory
${If} ${Errors}
@@ -64,6 +136,9 @@ Function .onGUIEnd
FunctionEnd
Function dshCleanupDirectories
${If} $dshRecoveryStopped == "1"
Return
${EndIf}
${If} $dshFinalDirectory != ""
Call dshRollbackDirectories
${EndIf}
@@ -77,11 +152,11 @@ Function dshRollbackDirectories
StrCpy $dshNewMoved ""
${EndIf}
${If} $dshOldMoved == "1"
ClearErrors
Rename $dshOldDirectory $dshFinalDirectory
${If} ${Errors}
!insertmacro dshDirectoryOperation "$dshOldDirectory" "$dshFinalDirectory" $dshRestoreError "restore"
${If} $dshRestoreError != 0
; Leave the complete backup in place if another process prevents restoration.
DetailPrint $dshOldDirectory
RMDir /r "\\?\$dshNewDirectory"
Return
${EndIf}
StrCpy $dshOldMoved ""
@@ -100,21 +175,35 @@ Function dshPromoteDirectories
SetOutPath $PLUGINSDIR
ClearErrors
${If} ${FileExists} "$dshFinalDirectory\*.*"
Rename $dshFinalDirectory $dshOldDirectory
${If} ${Errors}
StrCpy $dshDirectorySource $dshFinalDirectory
StrCpy $dshDirectoryDestination $dshOldDirectory
!insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError "backup"
${If} $dshDirectoryError != 0
Call dshRollbackDirectories
!insertmacro dshReportDirectoryFailure
SetErrors
Return
${EndIf}
StrCpy $dshOldMoved "1"
${Else}
; NSIS can create the destination before the install section starts.
RMDir $dshFinalDirectory
StrCpy $dshDirectorySource $dshFinalDirectory
StrCpy $dshDirectoryDestination ""
!insertmacro dshDirectoryOperation "$dshDirectorySource" "" $dshDirectoryError "remove-empty"
${If} $dshDirectoryError != 0
Call dshRollbackDirectories
!insertmacro dshReportDirectoryFailure
SetErrors
Return
${EndIf}
${EndIf}
ClearErrors
Rename $dshNewDirectory $dshFinalDirectory
${If} ${Errors}
StrCpy $dshDirectorySource $dshNewDirectory
StrCpy $dshDirectoryDestination $dshFinalDirectory
!insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError "promote"
${If} $dshDirectoryError != 0
Call dshRollbackDirectories
!insertmacro dshReportDirectoryFailure
SetErrors
Return
${EndIf}
+31
View File
@@ -195,6 +195,37 @@ ManifestDPIAware true
${EndIf}
!macroend
!macro customInstallerDirectoryFailed
Push $0
Push $1
Push $2
Push $3
Push $4
Push $5
Push $6
${GetTime} "" "L" $0 $1 $2 $3 $4 $5 $6
StrCpy $0 "$dshDirectoryLogDirectory\directory-failure-$2$1$0-$4$5$6.log"
StrCpy $1 1
${If} ${Silent}
StrCpy $1 0
${EndIf}
StrCpy $3 "$(INSTALLER_DIRECTORY_HINT)"
${If} $dshRestoreError != 0
StrCpy $3 "$(INSTALLER_DIRECTORY_RESTORE_HINT)"
${EndIf}
System::Call '$PLUGINSDIR\window-frame.dll::InstallerReportDirectoryFailure(p $HWNDPARENT, i $dshDirectoryError, w "$dshDirectorySource", w "$dshDirectoryDestination", i $dshRestoreError, w "$dshOldDirectory", w "$PLUGINSDIR\directory-operations.log", w r0, i r1, w "$(^SetupCaption)", w "$(INSTALLER_DIRECTORY_FAILED)", w r3, w "$(INSTALLER_EXTRACT_COPY)", w "$(INSTALLER_EXTRACT_EXPAND)", w "$(INSTALLER_EXTRACT_COLLAPSE)", w "$(INSTALLER_EXTRACT_SAVED)", w "$(INSTALLER_EXTRACT_UNSAVED)", w "$(INSTALLER_EXTRACT_COPIED)") i.r2 ?c'
${If} $2 == 1
DetailPrint $0
${EndIf}
Pop $6
Pop $5
Pop $4
Pop $3
Pop $2
Pop $1
Pop $0
!macroend
!ifndef BUILD_UNINSTALLER
!include "${__FILEDIR__}\installer-directories.nsh"
!endif
@@ -0,0 +1,10 @@
/** Reject identifiable debugging artifacts before installing publication dependencies. */
/**
* Pin the requested attempt and reject its explicitly named debugging artifacts.
* @param repository GitHub repository.
* @param inputs Dispatch inputs.
* @param api Parsed GitHub JSON.
* @returns Exact attempt for subsequent routing; local verification remains required.
*/
export function preflightDesktopPublish(repository: string, inputs: { package_run_id: string; package_run_attempt?: string },
api: (path: string, paginate?: boolean) => unknown): string
@@ -0,0 +1,47 @@
/** Reject identifiable debugging artifacts before installing publication dependencies. */
import { execFileSync } from 'node:child_process'
import { appendFile, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
/**
* Pin the requested attempt and reject its explicitly named debugging artifacts.
* Artifact names only reject; local inventory and signature verification still authorize publication.
* @param {string} repository GitHub repository.
* @param {{ package_run_id: string, package_run_attempt?: string }} inputs Dispatch inputs.
* @param {(path: string, paginate?: boolean) => unknown} api Parsed GitHub JSON; validated here without dependencies.
* @returns {string} Exact attempt for subsequent routing.
*/
export function preflightDesktopPublish(repository, inputs, api) {
if (!/^[\w.-]+\/[\w.-]+$/u.test(repository) || !/^[1-9]\d*$/u.test(inputs.package_run_id)
|| (inputs.package_run_attempt && !/^[1-9]\d*$/u.test(inputs.package_run_attempt))) {
throw new Error('desktop publish: repository, package_run_id and package_run_attempt must identify a valid packaging attempt')
}
const base = `repos/${repository}/actions/runs/${inputs.package_run_id}`
const latest = inputs.package_run_attempt ? undefined : api(base)
const attempt = inputs.package_run_attempt || String(latest && typeof latest === 'object' && 'run_attempt' in latest ? latest.run_attempt : '')
if (!/^[1-9]\d*$/u.test(attempt)) throw new Error('desktop publish: GitHub returned an invalid attempt')
const pages = api(`${base}/artifacts?per_page=100`, true)
if (!Array.isArray(pages) || pages.some(page => !Array.isArray(page.artifacts)
|| page.artifacts.some(artifact => typeof artifact.name !== 'string'))) {
throw new Error('desktop publish: GitHub returned invalid artifact metadata')
}
for (const { name } of pages.flatMap(page => page.artifacts)) {
if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue
const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned'
: name.startsWith('macos-arm64-unnotarized-debug-') ? 'unnotarized' : ''
const diagnostic = desktopPublishQualificationError(qualification)
if (diagnostic) throw new Error(diagnostic)
}
return attempt
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
const env = process.env
if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT) throw new Error('desktop publish: Actions context required')
const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'))
const attempt = preflightDesktopPublish(env.GITHUB_REPOSITORY, event.inputs, (path, paginate) =>
JSON.parse(execFileSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path],
{ encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 })))
await appendFile(env.GITHUB_OUTPUT, `attempt=${attempt}\n`)
}
+7 -4
View File
@@ -12,7 +12,9 @@ import { desktopPublishCos } from './desktop-publish-cos.ts'
import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from './desktop-publish-record.ts'
import { publishDesktopRelease } from './desktop-publish.ts'
import { desktopPublicationFailure } from './desktop-publication-failure.ts'
import { desktopPublicationSummary } from './desktop-publication-summary.ts'
import { publishDesktopTag } from './desktop-publish-tag.ts'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(),
sourceRef: z.string(), channel: z.enum(['nightly', 'latest']),
@@ -35,7 +37,9 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi
|| source.preset !== route.preset) {
throw new Error('desktop publish: local package differs from the routed request')
}
if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: debugging artifacts cannot be published')
const diagnostic = desktopPublishQualificationError(source.qualification)
if (diagnostic) throw new Error(diagnostic)
if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: package qualification does not match its platform')
const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, source.preset)
const directory = retainedDesktopPath(root, route.runId, route.attempt)
const environment = source.preset === 'test'
@@ -61,9 +65,8 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required')
if (mode === '--check') {
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`)
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY,
`\n## Confirm Desktop publication\n\nVersion: **${plan.version}**; channel: **${manifest.source.channel}**; environment: **${plan.environment}**; platform: **${plan.target}**\n\nSource: ${manifest.source.sourceRef} @ ${route.commit}\n\nPackage: ${route.runId}, attempt ${route.attempt}\n\nTag: ${plan.environment === 'production' ? `desktop-v${plan.version}` : 'none (test)'}\n\nApproved package SHA-256: ${approval}\n\n` +
plan.artifacts.map(file => `- ${file.channelMetadata ? 'Feed' : 'Artifact'}: ${file.key}`).join('\n') + '\n')
if (env.GITHUB_STEP_SUMMARY && !env.DESKTOP_APPROVED_PACKAGE) await appendFile(env.GITHUB_STEP_SUMMARY,
await desktopPublicationSummary(manifest.source, plan, approval))
if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT,
`app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`)
process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`)
@@ -62,6 +62,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
const env = process.env
if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT) throw new Error('desktop publish: Actions context required')
const event = z.object({ inputs: inputsSchema }).parse(JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8')))
const selected = selectDesktopPackage(env.GITHUB_REPOSITORY, event.inputs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}'), desktopPublishApi)
const inputs = { ...event.inputs, package_run_attempt: env.DESKTOP_PACKAGE_ATTEMPT || event.inputs.package_run_attempt }
const selected = selectDesktopPackage(env.GITHUB_REPOSITORY, inputs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}'), desktopPublishApi)
await appendFile(env.GITHUB_OUTPUT, `selection=${JSON.stringify(selected)}\nrunner=${selected.runnerLabel}\n`)
}
@@ -22,11 +22,11 @@ export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv,
const repository = env.GITHUB_REPOSITORY
const source = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${route.commit}`))
if (source.sha !== route.commit) throw new Error('desktop publish: source commit not found')
if (route.sourceRef === 'master') {
const comparison = z.object({ status: z.string() }).parse(await api(`repos/${repository}/compare/${route.commit}...master`))
if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on master')
if (!/^dsh-v[0-9][0-9A-Za-z.-]*$/u.test(route.sourceRef)) {
const comparison = z.object({ status: z.string() }).parse(await api(`repos/${repository}/compare/${route.commit}...${encodeURIComponent(`refs/heads/${route.sourceRef}`)}`))
if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on the selected repository branch')
} else {
const tagged = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${route.sourceRef}`))
const tagged = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${encodeURIComponent(`refs/tags/${route.sourceRef}`)}`))
if (tagged.sha !== route.commit) throw new Error('desktop publish: source tag changed')
}
const environment = z.object({ protection_rules: z.array(z.object({ type: z.string(), reviewers: z.array(z.unknown()).optional() })) })
+4 -1
View File
@@ -6,6 +6,7 @@ import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDeskt
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts'
import { validateDesktopBuildVersion } from './desktop-build-version.mjs'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]
@@ -66,8 +67,10 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(
}
const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name)))
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`)
const nextStep = desktopPublishQualificationError(qualification)
?? `Publish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}`
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY,
`\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\nPublish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}\n`)
`\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\n${nextStep}\n`)
return retained
}
@@ -0,0 +1,31 @@
# Repeating Task Scheduler triggers recover an exited launcher without relying on action exit-code retries.
$ErrorActionPreference = 'Stop'
$runnerRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)).TrimEnd('\','/')
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($runnerRoot.ToUpperInvariant())))
$taskName = 'DSH-Packaging-' + $digest.Substring(0,16)
$log = Join-Path $PSScriptRoot ('run-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '-' + $PID + '.log')
try {
@{ Pid = $PID; Started = (Get-Process -Id $PID).StartTime.ToUniversalTime().Ticks } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'process.json')
$config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'config.json') -Raw | ConvertFrom-Json
$attemptFile = Join-Path $PSScriptRoot 'attempts.txt'
$attempts = [int](Get-Content -LiteralPath $attemptFile -Raw)
if ($attempts -lt 0 -or $config.RestartCount -lt 1 -or $config.RestartCount -gt 999) { throw 'Invalid restart budget; reinstall the task.' }
if ($attempts -gt $config.RestartCount) {
$null = Disable-ScheduledTask -TaskName $config.TaskName -TaskPath '\'
Set-Content -LiteralPath $log -Value 'Restart budget exhausted. Task disabled; inspect runner logs, fix the cause and run start.'
exit 1
}
Set-Content -LiteralPath $attemptFile -Value ($attempts + 1)
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'start.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log
$code = $LASTEXITCODE
Add-Content -LiteralPath $log -Value "Runner launcher exited with code $code."
# An unexpected exit is a task failure even when run.cmd hides the listener's nonzero status; the log retains its code.
exit 1
} catch {
# A missing or unwritable counter cannot bound retries; the task name must not depend on readable configuration.
$null = Disable-ScheduledTask -TaskName $taskName -TaskPath '\'
$message = 'Runner task failed: ' + $_.Exception.Message
[Console]::Error.WriteLine($message)
Add-Content -LiteralPath $log -Value $message
exit 1
}
@@ -29,7 +29,7 @@ try {
$brokenArchive = Join-Path $scratch 'broken.7z'
[System.IO.File]::WriteAllText($brokenArchive, 'invalid archive')
foreach ($mode in @('first', 'upgrade', 'locked', 'missing-stage', 'broken', 'cancelled')) {
foreach ($mode in @('first', 'upgrade', 'transient-lock', 'locked', 'locked-stage', 'missing-stage', 'restore-conflict', 'broken', 'cancelled')) {
$caseRoot = Join-Path $scratch $mode
$target = Join-Path $caseRoot 'Application'
New-Item -ItemType Directory -Path $caseRoot | Out-Null
@@ -47,34 +47,83 @@ try {
$compileArgs = @('/V2', "/DOUTPUT_FILE=$probe", "/DPAYLOAD_FILE=$caseArchive", "/DTARGET_DIR=$target", "/DDSH_SEVENZIP_PATH=$SevenZip")
if ($FrameLibrary) { $compileArgs += @("/DSOURCE_DLL=$FrameLibrary", "/DREPORT_DIR=$reportDir") }
if ($mode -eq 'missing-stage') { $compileArgs += '/DMISSING_STAGE' }
if ($mode -eq 'locked-stage') { $compileArgs += '/DLOCKED_STAGE' }
if ($mode -eq 'restore-conflict') { $compileArgs += '/DRESTORE_CONFLICT' }
if ($mode -eq 'cancelled') { $compileArgs += '/DCANCELLED' }
Invoke-Checked $Makensis ($compileArgs + $fixture)
if ($null -ne $SignExecutable) { & $SignExecutable $probe }
$handle = $null
$process = $null
try {
if ($mode -eq 'locked') {
if ($mode -in @('locked', 'transient-lock')) {
$handle = [System.IO.File]::Open((Join-Path $target 'asset.txt'), 'Open', 'Read', 'Read')
}
$process = Start-Process -FilePath $probe -ArgumentList '/S' -WindowStyle Hidden -PassThru
if ($mode -eq 'transient-lock') {
$deadline = [Diagnostics.Stopwatch]::StartNew()
$observedFailure = $false
while (-not $process.HasExited -and $deadline.Elapsed.TotalSeconds -lt 30) {
$pluginPath = "$target.plugin-dir"
if (Test-Path -LiteralPath $pluginPath) {
$trace = Join-Path ([IO.File]::ReadAllText($pluginPath)) 'directory-operations.log'
if (Test-Path -LiteralPath $trace) {
$stream = [IO.File]::Open($trace, 'Open', 'Read', 'ReadWrite')
$reader = [IO.StreamReader]::new($stream)
try { $attempts = $reader.ReadToEnd() } finally { $reader.Dispose() }
if ($attempts -match 'Windows error: (5|32|33)\b') {
$observedFailure = $true
break
}
}
}
Start-Sleep -Milliseconds 20
}
if (-not $observedFailure) { throw 'Transient-lock smoke did not observe the failed operation before release' }
$handle.Dispose()
$handle = $null
}
if (-not $process.WaitForExit(60000)) {
$process.Kill($true)
$process.WaitForExit()
throw "Directory smoke $mode did not exit within 60 seconds"
}
$expectedExit = if ($mode -in @('first', 'upgrade')) { 0 } else { 2 }
$expectedExit = if ($mode -in @('first', 'upgrade', 'transient-lock')) { 0 } else { 2 }
if ($process.ExitCode -ne $expectedExit) {
throw "Directory smoke $mode exited with $($process.ExitCode), expected $expectedExit"
}
} finally {
if ($null -ne $handle) { $handle.Dispose() }
if ($null -ne $process) { $process.Dispose() }
if ($null -ne $process) {
if (-not $process.HasExited) { $process.Kill($true); $process.WaitForExit() }
$process.Dispose()
}
}
$expectedAsset = if ($mode -in @('first', 'upgrade')) { 'new asset' } else { 'old asset' }
if ($mode -eq 'restore-conflict') {
$backups = @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter 'Application.old-*')
if ($backups.Count -ne 1 -or [IO.File]::ReadAllText((Join-Path $backups[0].FullName 'asset.txt')) -ne 'old asset' -or
[IO.File]::ReadAllText((Join-Path $backups[0].FullName 'obsolete.txt')) -ne 'old only' -or
[IO.File]::ReadAllText((Join-Path "$target.conflict" 'unrelated.txt')) -ne 'unrelated file') {
throw 'Failed restoration did not preserve the backup and the conflicting destination'
}
if ((Test-Path -LiteralPath $target) -or @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter 'Application.new-*').Count -ne 0) {
throw 'Reported restoration failure retried recovery or retained its unlocked staging directory'
}
if ($FrameLibrary) {
$reports = @(Get-ChildItem -LiteralPath $reportDir -File)
if ($reports.Count -ne 1) { throw 'Failed restoration did not save exactly one report' }
$report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8
foreach ($expected in @('promote: Windows error: 183', 'restore: Windows error: 183', 'Restoration error: 183', "Preserved backup: $($backups[0].FullName)")) {
if (-not $report.Contains($expected)) { throw "Failed restoration report lacks '$expected'" }
}
}
Write-Output "Directory replacement smoke passed: $mode"
continue
}
$expectedAsset = if ($mode -in @('first', 'upgrade', 'transient-lock')) { 'new asset' } else { 'old asset' }
if ([System.IO.File]::ReadAllText((Join-Path $target 'asset.txt')) -ne $expectedAsset) {
throw "Directory smoke $mode changed the wrong version"
}
$expectObsolete = $mode -notin @('first', 'upgrade')
$expectObsolete = $mode -notin @('first', 'upgrade', 'transient-lock')
if ((Test-Path -LiteralPath (Join-Path $target 'obsolete.txt')) -ne $expectObsolete) {
throw "Directory smoke $mode did not preserve the expected old-only file state"
}
@@ -82,7 +131,6 @@ try {
throw "Directory smoke $mode left transaction directories behind"
}
if ($FrameLibrary) {
# Only an extraction failure writes a report; it must quote 7-Zip's verdict so support can read the cause.
$reports = @(if (Test-Path -LiteralPath $reportDir) { Get-ChildItem -LiteralPath $reportDir -File })
if ($mode -eq 'broken') {
if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^extract-failure-20\d{6}-\d{6}\.log$') {
@@ -92,6 +140,19 @@ try {
foreach ($expected in @('Result: 7-Zip exit code 2 (fatal error)', "Archive: $caseArchive", 'Cannot open the file as [7z] archive')) {
if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" }
}
} elseif ($mode -in @('locked', 'locked-stage', 'missing-stage')) {
if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^directory-failure-20\d{6}-\d{6}\.log$') {
throw "Directory smoke $mode did not save exactly one directory report"
}
$report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8
$expectedError = if ($mode -ne 'missing-stage') { '(5|32|33)' } else { '2' }
if ($report -notmatch "Windows error: $expectedError\b") { throw "Directory smoke $mode reported an unexpected Windows error" }
foreach ($expected in @('Operation: MoveFileExW', 'Restoration error: 0')) {
if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" }
}
$attempts = ([regex]::Matches($report, 'Windows error: ')).Count - 1
$expectedAttempts = if ($mode -ne 'missing-stage') { 10 } else { 1 }
if ($attempts -ne $expectedAttempts) { throw "Directory smoke $mode recorded $attempts failed attempts, expected $expectedAttempts" }
} elseif ($reports.Count -ne 0) {
throw "Directory smoke $mode saved an unexpected extraction report"
}
@@ -37,7 +37,7 @@ export function directoryInstallSection(source) {
!macroend`)
result = replaceOnce(result, '!insertmacro setLinkVars', `!insertmacro setLinkVars
!insertmacro dshStageApplication`)
result = replaceOnce(result, '!insertmacro installApplicationFiles', 'Call dshPromoteDirectories\nIfErrors 0 +4\n SetErrorLevel 2\n MessageBox MB_OK|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDOK\n Quit')
result = replaceOnce(result, '!insertmacro installApplicationFiles', 'Call dshPromoteDirectories\nIfErrors 0 +3\n SetErrorLevel 2\n Quit')
result = replaceOnce(result, '!ifdef UNINSTALLER_ICON\n File /oname=uninstallerIcon.ico "${UNINSTALLER_ICON}"\n!endif\n', '')
// The staging macro uses the upstream installer macro, including its signed uninstaller.
return result
@@ -0,0 +1,122 @@
# Manage a registered packaging runner in the current user's interactive Windows session.
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][ValidateSet('install','start','stop','status','uninstall')][string]$Action,
[Parameter(Mandatory=$true)][string]$RunnerRoot,
[string]$ConfigRoot,
[ValidateRange(1,1440)][int]$RestartMinutes = 1,
[ValidateRange(1,999)][int]$RestartCount = 10
)
$ErrorActionPreference = 'Stop'
if (-not $IsWindows) { throw 'This command requires PowerShell 7 on Windows.' }
if ($Action -ne 'install' -and @('ConfigRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) {
throw 'ConfigRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.'
}
if ($RunnerRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'RunnerRoot must be an absolute local directory.' }
$RunnerRoot = [IO.Path]::GetFullPath($RunnerRoot).TrimEnd('\','/')
if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot '.runner') -PathType Leaf)) { throw 'Register the runner before managing its task.' }
for ($ancestor = [IO.DirectoryInfo]::new($RunnerRoot); $null -ne $ancestor; $ancestor = $ancestor.Parent) {
if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'RunnerRoot cannot contain linked directories.' }
}
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($RunnerRoot.ToUpperInvariant())))
$taskName = 'DSH-Packaging-' + $digest.Substring(0,16)
$serviceDir = Join-Path $RunnerRoot 'dsh-task'
$task = Get-ScheduledTask -TaskPath '\' | Where-Object TaskName -eq $taskName
if ($task -and $task.Description -ne "DSH packaging runner: $RunnerRoot; owner: $sid") { throw 'Task ownership differs; inspect it in Task Scheduler.' }
if ($Action -eq 'install') {
if ($task) { throw 'Task already installed; stop and uninstall before reinstalling.' }
if (-not $ConfigRoot -or $ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'Install requires an absolute ConfigRoot.' }
$ConfigRoot = [IO.Path]::GetFullPath($ConfigRoot)
$pwsh = Join-Path $PSHOME 'pwsh.exe'
& $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly
if ($LASTEXITCODE -ne 0) { throw 'Runner preflight failed; no task was installed.' }
if (Test-Path -LiteralPath $serviceDir) {
if ((Get-Item -LiteralPath $serviceDir).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Task directory cannot be a link.' }
} else {
$null = New-Item -ItemType Directory -Path $serviceDir
}
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1')
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1')
@{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8
Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0'
$arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -File "' + (Join-Path $serviceDir 'run.ps1') + '"'
$execution = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments -WorkingDirectory $RunnerRoot
$principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited
$trigger = @(
(New-ScheduledTaskTrigger -AtLogOn -User $sid),
(New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes($RestartMinutes) -RepetitionInterval (New-TimeSpan -Minutes $RestartMinutes))
)
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$settings.Enabled = $false
$null = Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $execution -Principal $principal -Trigger $trigger -Settings $settings -Description "DSH packaging runner: $RunnerRoot; owner: $sid"
Write-Output "Installed disabled task $taskName. Run start while logged in; reboot requires desktop login. Logs: $serviceDir"
exit 0
}
if (-not $task) { throw 'Task is not installed for this runner directory.' }
switch ($Action) {
'status' {
$info = Get-ScheduledTaskInfo -InputObject $task
$launches = [int](Get-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Raw)
$config = Get-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Raw | ConvertFrom-Json
[pscustomobject]@{ TaskName = $taskName; State = $task.State; LastRunTime = $info.LastRunTime; LastTaskResult = $info.LastTaskResult; Launches = $launches; RemainingLaunches = [Math]::Max(0, 1 + $config.RestartCount - $launches); Logs = $serviceDir }
}
'start' {
if ($task.State -eq 'Running') { Write-Output "$taskName is already running."; break }
Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0'
$null = Enable-ScheduledTask -InputObject $task
Start-ScheduledTask -InputObject $task
Write-Output "Start requested for $taskName. Verify Connected to GitHub and Listening for Jobs in $serviceDir."
}
'stop' {
# Operators pause workflow dispatch first; this local check cannot reserve an idle GitHub runner.
$workers = Get-CimInstance Win32_Process -Filter "Name = 'Runner.Worker.exe'"
foreach ($worker in $workers) {
if (-not $worker.ExecutablePath -or $worker.ExecutablePath.StartsWith($RunnerRoot + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw 'A runner job is active or its location is unreadable. Wait for completion before stopping.'
}
}
$null = Disable-ScheduledTask -InputObject $task
$task = Get-ScheduledTask -TaskName $taskName -TaskPath '\'
if ($task.State -eq 'Running') {
$recordFile = Join-Path $serviceDir 'process.json'
if (-not (Test-Path -LiteralPath $recordFile)) { throw 'Running task process identity is unavailable. Automatic starts are disabled; inspect the task before stopping it.' }
$record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json
$owner = Get-Process -Id $record.Pid -ErrorAction SilentlyContinue
if (-not $owner -or $owner.StartTime.ToUniversalTime().Ticks -ne $record.Started) {
throw 'Running task process identity is unavailable. Automatic starts are disabled; inspect the task before stopping it.'
}
# Parent IDs can be reused; this snapshot supplies wait handles, not termination authority.
$processes = @(Get-CimInstance Win32_Process)
$ownedIds = [Collections.Generic.HashSet[int]]::new()
$null = $ownedIds.Add($owner.Id)
do {
$added = $false
foreach ($process in $processes) {
if ($ownedIds.Contains([int]$process.ParentProcessId) -and $ownedIds.Add([int]$process.ProcessId)) { $added = $true }
}
} while ($added)
$owned = @(foreach ($ownedId in $ownedIds) { Get-Process -Id $ownedId -ErrorAction SilentlyContinue })
& "$env:SystemRoot/System32/taskkill.exe" /PID $owner.Id /T /F
if ($LASTEXITCODE -ne 0 -and -not $owner.HasExited) { throw 'Runner task process tree could not be stopped; inspect it before restarting.' }
foreach ($process in $owned) {
if (-not $process.WaitForExit(30000)) { throw 'Runner task child remains alive; inspect it before restarting.' }
}
}
Stop-ScheduledTask -InputObject $task
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
$state = (Get-ScheduledTask -TaskName $taskName -TaskPath '\').State
if ($state -ne 'Running') { break }
Start-Sleep -Milliseconds 200
} while ([DateTime]::UtcNow -lt $deadline)
if ($state -eq 'Running') { throw 'Task remains running; inspect its processes before further maintenance.' }
Write-Output "Stopped and disabled $taskName. Run start to enable automatic starts again."
}
'uninstall' {
if ($task.State -ne 'Disabled') { throw 'Stop the task before uninstalling.' }
Unregister-ScheduledTask -InputObject $task -Confirm:$false
Write-Output "Uninstalled $taskName. Registration, configuration and logs remain in $RunnerRoot."
}
}
@@ -95,14 +95,24 @@ it('resolves an old locally retained package with publishRequested=false without
await expect(resolveRetainedDesktopPublication(env, path => path.includes('/environments/') ? { protection_rules: [] } : api(path)))
.rejects.toThrow(/reviewers/u)
await expect(resolveRetainedDesktopPublication(env, path => path.includes('/compare/') ? { status: 'diverged' } : api(path)))
.rejects.toThrow(/not on master/u)
.rejects.toThrow(/not on the selected repository branch/u)
const manifestPath = join(root, 'releases/42/2/manifest.json')
const original = await readFile(manifestPath, 'utf8')
const tagged = await verifyRetainedDesktopRelease(join(root, 'releases'), '42', '2')
tagged.source.sourceRef = 'feature/desktop-update'
await writeFile(manifestPath, JSON.stringify(tagged))
expect(await resolveRetainedDesktopPublication(env, api)).toMatchObject({ sourceRef: 'feature/desktop-update', commit: source.commit })
expect(apiCalls).toContain(`repos/owner/repo/compare/${source.commit}...refs%2Fheads%2Ffeature%2Fdesktop-update`)
await expect(resolveRetainedDesktopPublication(env, path => path.includes('/compare/') ? { status: 'diverged' } : api(path)))
.rejects.toThrow(/selected repository branch/u)
await expect(resolveRetainedDesktopPublication(env, (path) => {
if (path.includes('/compare/')) throw new Error('branch deleted or missing')
return api(path)
})).rejects.toThrow(/branch deleted/u)
tagged.source.sourceRef = 'dsh-v1.0.0'
await writeFile(manifestPath, JSON.stringify(tagged))
expect(await resolveRetainedDesktopPublication(env, api)).toMatchObject({ sourceRef: 'dsh-v1.0.0' })
await expect(resolveRetainedDesktopPublication(env, path => path.endsWith('/commits/dsh-v1.0.0') ? { sha: 'c'.repeat(40) } : api(path)))
await expect(resolveRetainedDesktopPublication(env, path => path.endsWith('/commits/refs%2Ftags%2Fdsh-v1.0.0') ? { sha: 'c'.repeat(40) } : api(path)))
.rejects.toThrow(/source tag changed/u)
await writeFile(manifestPath, original)
await writeFile(join(root, 'releases/42/2/installer.dmg'), 'changed')
Loaded 100 of 1388 files, more files were not shown because too many files have changed in this diff. Show more