dsh-wsl-desktop 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -157,6 +157,7 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
157
157
  - [Distro support matrix](docs/DISTRO-SUPPORT.md) — which distros were verified, and what each one needs
158
158
  - [Compatibility and upgrades](docs/COMPATIBILITY.md) — plugin/desktop version relation, upgrade order, rollback steps
159
159
  - [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the two proposals to the harness upstream
160
+ - [Upstream defect reports](issues/README.md) — the harness issues filed, their measured evidence, and how the local workarounds relate
160
161
  - [Architecture and flow diagrams](docs/diagrams.md) — interactive architecture, sequence and lifecycle diagrams
161
162
 
162
163
  Three of the linked documents — the distro support matrix, the upstream proposals and the diagram
package/README.md CHANGED
@@ -130,6 +130,7 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
130
130
  - [发行版支持矩阵](docs/DISTRO-SUPPORT.md) — 哪些发行版验证过,每种缺什么
131
131
  - [兼容性与升级](docs/COMPATIBILITY.md) — 插件与桌面的版本关系、升级顺序、回退步骤
132
132
  - [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness 上游的两项提案
133
+ - [上游缺陷记录](issues/README.md) — 已上报 harness 的问题、实测证据与本地规避的关系
133
134
  - [架构与流程图](docs/diagrams.md) — 可交互的架构、时序与生命周期图
134
135
 
135
136
  其中三份——发行版支持矩阵、上游 API 提案、架构与流程图索引——**只有中文版**,因为目前没有对应的
package/lib/client.js CHANGED
@@ -21,8 +21,23 @@ window.__ModuleLoader__.load({
21
21
  id: 'dsh-wsl-desktop',
22
22
  factory() {
23
23
  // MUST stay equal to the host half's Config.routePath default (lib/index.js).
24
+ /*
25
+ * Section index. This file is ONE loader module: `window.__ModuleLoader__`
26
+ * evaluates a single factory, and splitting it across files would need a
27
+ * loader capability that has not been measured. The boundaries below are
28
+ * therefore comments, not files — they exist so a reader can hold one
29
+ * section at a time, and so a future split has its seams already named.
30
+ * 1. endpoint, trigger geometry and locale
31
+ * 2. copy, theme and shared node helpers
32
+ * 3. route calls and failure rendering
33
+ * 4. the W workspace picker dialog
34
+ * 5. the companion button and workspace rows
35
+ * 6. module registration and dispose
36
+ */
37
+
24
38
  // Nothing derives one from the other: a deployment that mounts the route
25
39
  // elsewhere loses the picker silently.
40
+ // ── 1. endpoint, trigger geometry and locale ─────────────────────────────
26
41
  const ENDPOINT = '/wsl-desktop/api'
27
42
 
28
43
  /**
@@ -38,6 +53,34 @@ window.__ModuleLoader__.load({
38
53
  'M5.54492 2.06738', // post-refactor ProjectAddOutlineArtwork
39
54
  ]
40
55
 
56
+ /**
57
+ * The same trigger, named by sprite symbol instead of path data.
58
+ *
59
+ * Desktop 0.2.1-rc3 (ui-primitives `c3eead771d`) moved every product icon
60
+ * into the document icon sprite: the header button now renders
61
+ * `<svg data-icon="dsw-icon-project-add-outline-regular"><use href="#…"/></svg>`
62
+ * with NO `<path>` child anywhere. The geometry scan above therefore matched
63
+ * nothing, the anchor read as absent, and the W button stood down on every
64
+ * 0.2.1-rc3 desktop. The glyph name is the locale-independent identity in
65
+ * that generation, exactly as the path prefix was in the previous one, and a
66
+ * theme may replace the artwork without renaming the symbol — so the name
67
+ * survives the change that broke the geometry.
68
+ */
69
+ const TRIGGER_ICON_GLYPHS = ['project-add-outline']
70
+
71
+ /** Sprite symbol id shape, as `iconSymbolId` composes it: `dsw-icon-<glyph>-<weight>`. */
72
+ const SPRITE_ICON_ID = /^#?dsw-icon-(.+)-(?:regular|medium)$/
73
+
74
+ /**
75
+ * The row glyphs whose artwork is FILL-only, as sprite names.
76
+ *
77
+ * Only the fallback for a sprite symbol that is not in the document yet; the
78
+ * live read (`isSolidIcon`) prefers the symbol's own geometry. Versions
79
+ * qualify the id (`symbolId(glyph, weight)`), so the suffix is not part of a
80
+ * glyph name and the match is on the glyph alone.
81
+ */
82
+ const SOLID_GLYPHS = ['folder-open']
83
+
41
84
  /** Namespace of every SVG node this half creates. */
42
85
  const SVG_NS = 'http://www.w3.org/2000/svg'
43
86
 
@@ -74,6 +117,7 @@ window.__ModuleLoader__.load({
74
117
  * The catalog is bilingual, so the dialog can render a host failure in the
75
118
  * operator's language without a translation build step.
76
119
  */
120
+ // ── 2. copy, theme and shared node helpers ─────────────────────────────
77
121
  const LOCALE = typeof navigator !== 'undefined' && /^zh/i.test(String(navigator.language ?? '')) ? 'zh' : 'en'
78
122
 
79
123
  /**
@@ -288,6 +332,7 @@ window.__ModuleLoader__.load({
288
332
  * @param {object|null} envelope - the route's JSON body, when it parsed.
289
333
  * @returns {Error} the failure, with `code` and `params` attached.
290
334
  */
335
+ // ── 3. route calls and failure rendering ─────────────────────────────
291
336
  function routeFailure(method, status, envelope) {
292
337
  const error = new Error(envelope?.error ?? `${method} failed (HTTP ${status})`)
293
338
  error.code = typeof envelope?.code === 'string' ? envelope.code : status === 0 ? 'transport' : 'internal'
@@ -705,6 +750,7 @@ window.__ModuleLoader__.load({
705
750
  * so lastIndexOf returned -1 and slice(0, -1) gave 'hom'/'tm' — the host
706
751
  * listed /tmp while the "up" button navigated to /tm.
707
752
  */
753
+ // ── 4. the W workspace picker dialog ─────────────────────────────
708
754
  function parentOf(path) {
709
755
  const absolute = path.startsWith('/') ? path : `/${path}`
710
756
  const trimmed = absolute.replace(/\/+$/, '')
@@ -1471,20 +1517,74 @@ window.__ModuleLoader__.load({
1471
1517
  }
1472
1518
 
1473
1519
  /** The injected services the picker and its trigger read. */
1520
+ // ── 5. the companion button and workspace rows ─────────────────────────────
1474
1521
  const companion = { button: null, fallback: null, trigger: null, observer: null, scheduled: false, disposed: false }
1475
1522
 
1476
1523
  /** Whether the missing-workspace-source warning was already printed. */
1477
1524
  let warnedWorkspaceSource = false
1478
1525
 
1479
- /** Find the shipped "add workspace" trigger by its icon geometry. */
1480
- function findTrigger() {
1526
+ /**
1527
+ * The sprite glyph one `<use>` reference names, or null.
1528
+ *
1529
+ * Accepts both the bare symbol id and the `#`-prefixed href form, and both
1530
+ * `href` and the legacy `xlink:href` spelling, because an icon may render
1531
+ * either. Returning the GLYPH (not the whole id) lets the trigger list stay
1532
+ * a list of glyph names, which is what a desktop generation actually fixes.
1533
+ * @param {Element} node - a `<use>` element.
1534
+ * @returns {string | null} the glyph name, or null when it is not a sprite ref.
1535
+ */
1536
+ function spriteGlyphOf(node) {
1537
+ const ref = node.getAttribute('href')
1538
+ ?? node.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
1539
+ if (ref === null) return null
1540
+ return SPRITE_ICON_ID.exec(ref)?.[1] ?? null
1541
+ }
1542
+
1543
+ /**
1544
+ * The candidate trigger buttons carrying one generation's icon identity.
1545
+ *
1546
+ * Each generation is ONE document-level selector rather than a walk over
1547
+ * every button: `findTrigger` re-runs on every animation frame whenever the
1548
+ * anchor is missing, so a per-button subtree query would turn a single scan
1549
+ * into N of them exactly when the header is already in trouble.
1550
+ * @param {string} selector - the icon nodes this generation renders.
1551
+ * @param {(node: Element) => boolean} identifies - whether one icon is the trigger's.
1552
+ * @returns {HTMLElement[]} the distinct buttons owning a matching icon.
1553
+ */
1554
+ function triggerCandidates(selector, identifies) {
1481
1555
  const matches = []
1482
- for (const path of document.querySelectorAll('svg > path[d]')) {
1483
- const d = path.getAttribute('d')
1484
- if (!TRIGGER_ICON_PATHS.some((geometry) => d.startsWith(geometry))) continue
1485
- const trigger = path.closest('button')
1556
+ for (const icon of document.querySelectorAll(selector)) {
1557
+ if (!identifies(icon)) continue
1558
+ const trigger = icon.closest('button')
1486
1559
  if (trigger !== null && !matches.includes(trigger)) matches.push(trigger)
1487
1560
  }
1561
+ return matches
1562
+ }
1563
+
1564
+ /** Whether one inline path carries the add-workspace geometry. */
1565
+ function isTriggerPath(path) {
1566
+ const d = path.getAttribute('d')
1567
+ return TRIGGER_ICON_PATHS.some((geometry) => d.startsWith(geometry))
1568
+ }
1569
+
1570
+ /**
1571
+ * Whether one icon node is the add-workspace artwork, in either generation.
1572
+ *
1573
+ * A sprite icon is identified by `data-icon` (the id the component was
1574
+ * CONSTRUCTED with, which a theme replacement does not change) and, failing
1575
+ * that, by the `<use>` reference it renders.
1576
+ * @param {Element} icon - an inline path or a sprite `<svg>`/`<use>`.
1577
+ * @returns {boolean} true when it is the trigger's artwork.
1578
+ */
1579
+ function isTriggerIcon(icon) {
1580
+ if (icon.tagName === 'path') return isTriggerPath(icon)
1581
+ const declared = icon.tagName === 'svg' ? icon.getAttribute('data-icon') : null
1582
+ const glyph = declared !== null ? SPRITE_ICON_ID.exec(declared)?.[1] : spriteGlyphOf(icon)
1583
+ return glyph !== undefined && glyph !== null && TRIGGER_ICON_GLYPHS.includes(glyph)
1584
+ }
1585
+
1586
+ /** Pick the trigger out of one generation's candidates, or null. */
1587
+ function pickTrigger(matches) {
1488
1588
  if (matches.length <= 1) return matches[0] ?? null
1489
1589
  // More than one control carries the geometry. The shipped trigger is the
1490
1590
  // one inside a `*_headerActions` cluster — the CSS-module local name is
@@ -1494,6 +1594,24 @@ window.__ModuleLoader__.load({
1494
1594
  return matches.find((trigger) => hasHeaderActionsParent(trigger)) ?? null
1495
1595
  }
1496
1596
 
1597
+ /**
1598
+ * Find the shipped "add workspace" trigger in either desktop generation.
1599
+ *
1600
+ * The sprite generation is scanned FIRST because it is the newer one: on a
1601
+ * desktop that renders it, that single scan is the whole cost. Only a
1602
+ * desktop with no sprite icon anywhere falls through to the geometry scan,
1603
+ * which is what every release before 0.2.1-rc3 needed.
1604
+ * @returns {HTMLElement | null} the anchor, or null when this desktop has none.
1605
+ */
1606
+ function findTrigger() {
1607
+ const sprite = triggerCandidates('svg[data-icon], use', isTriggerIcon)
1608
+ // A non-empty sprite result is this desktop's answer even when it is
1609
+ // ambiguous: `pickTrigger` returns null there to mean "stand down", and
1610
+ // falling through would let an unrelated inline icon pick the anchor.
1611
+ if (sprite.length > 0) return pickTrigger(sprite)
1612
+ return pickTrigger(triggerCandidates('svg > path[d]', isTriggerIcon))
1613
+ }
1614
+
1497
1615
  /**
1498
1616
  * Whether one trigger sits in the shipped trailing action cluster.
1499
1617
  * @param {HTMLElement} trigger - a candidate trigger button.
@@ -1637,7 +1755,7 @@ window.__ModuleLoader__.load({
1637
1755
  // artwork fills its paths, the collapsed one strokes them. A rebuild is
1638
1756
  // owed whenever that flips, which is exactly what expanding a workspace
1639
1757
  // does to this span.
1640
- const solid = shipped !== null && shipped.querySelector('path[stroke]') === null
1758
+ const solid = isSolidIcon(shipped)
1641
1759
  const weight = solid ? 'solid' : 'outline'
1642
1760
  if (mine !== null && mine.dataset.wslCloudWeight !== weight) {
1643
1761
  mine.remove()
@@ -1648,6 +1766,56 @@ window.__ModuleLoader__.load({
1648
1766
  }
1649
1767
  }
1650
1768
 
1769
+ /**
1770
+ * The sprite symbol an icon element references, as a bare symbol id.
1771
+ *
1772
+ * `data-icon` is the id the icon COMPONENT was built with, so it keeps the
1773
+ * shipped symbol even when a theme points the `<use>` href at its own
1774
+ * artwork; the href is the fallback for markup that omits `data-icon`.
1775
+ * @param {Element} icon - one icon `<svg>`.
1776
+ * @returns {string | null} the symbol id without `#`, or null.
1777
+ */
1778
+ function spriteSymbolOf(icon) {
1779
+ const declared = icon.getAttribute('data-icon')
1780
+ if (typeof declared === 'string' && declared !== '') return declared.replace(/^#/, '')
1781
+ for (const use of icon.querySelectorAll('use')) {
1782
+ const ref = use.getAttribute('href')
1783
+ ?? use.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
1784
+ if (typeof ref === 'string' && ref !== '') return ref.replace(/^#/, '')
1785
+ }
1786
+ return null
1787
+ }
1788
+
1789
+ /**
1790
+ * Whether one workspace row icon renders the FILL-only artwork.
1791
+ *
1792
+ * The collapsed row strokes its folder outline and the expanded one fills
1793
+ * it, and the cloud has to match whichever is showing. Up to 0.2.1-rc2 the
1794
+ * artwork was inline, so a stroked `<path>` was the tell. From 0.2.1-rc3 the
1795
+ * icon is a sprite reference with NO path child anywhere: the weight then
1796
+ * belongs to the SYMBOL in the document sprite, not to the `<svg>` naming
1797
+ * it. Reading only the inline shape (as this did) made every sprite row look
1798
+ * fill-only, so a collapsed row got the solid cloud.
1799
+ * @param {Element | null} icon - the shipped folder icon, if the row renders one.
1800
+ * @returns {boolean} true when the icon is the fill-only artwork.
1801
+ */
1802
+ function isSolidIcon(icon) {
1803
+ if (icon === null) return false
1804
+ // Inline generation: the artwork declares its own weight.
1805
+ if (icon.querySelector('path[d]') !== null) return icon.querySelector('path[stroke]') === null
1806
+ const symbolId = spriteSymbolOf(icon)
1807
+ if (symbolId === null) return false
1808
+ const symbol = document.getElementById(symbolId)
1809
+ // Sprite generation, symbol present: judge the artwork the browser paints,
1810
+ // so a theme's own geometry is measured rather than assumed.
1811
+ if (symbol !== null) return symbol.querySelector('path[stroke]') === null
1812
+ // Sprite generation, symbol not in the document (a theme may mount its
1813
+ // own sprite later): fall back to the glyph name, which is the fill-only
1814
+ // identity in the shipped table.
1815
+ const glyph = SPRITE_ICON_ID.exec(`#${symbolId}`)?.[1]
1816
+ return glyph !== undefined && glyph !== null && SOLID_GLYPHS.includes(glyph)
1817
+ }
1818
+
1651
1819
  /**
1652
1820
  * Whether one observed mutation could have changed a workspace row's markup.
1653
1821
  *
@@ -1904,6 +2072,8 @@ window.__ModuleLoader__.load({
1904
2072
  }
1905
2073
  }
1906
2074
 
2075
+
2076
+ // ── 6. module registration and dispose ─────────────────────────────
1907
2077
  return {
1908
2078
  // `remote.session` is the namespace the create request travels through
1909
2079
  // (see `createBoundSession`); the harness's own client plugins inject it
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The route's HTTP seam: reading a JSON body, answering with one, and deciding
3
+ * whether a caller may reach the development-token surface.
4
+ *
5
+ * Extracted verbatim from `lib/index.js` (C3). Nothing in here is new behaviour;
6
+ * the move exists so the plugin entry file carries wiring and lifecycle rather
7
+ * than request plumbing.
8
+ * @module dsh-wsl-desktop/host/http
9
+ */
10
+
11
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
12
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+ import { MAX_BODY_BYTES, DEV_TOKEN_HEADER, tokenMatches } from '../http-admission.js'
16
+
17
+ /**
18
+ * The harness home that holds the user preset root.
19
+ * @returns {string} the resolved `$DSH_HOME`.
20
+ */
21
+ export function dshHome() {
22
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh')
23
+ }
24
+
25
+ /**
26
+ * Read one JSON request body, bounded.
27
+ * @param {import('node:http').IncomingMessage} req - the request to drain.
28
+ * @returns {Promise<{ text: string | null, byteLength: number }>} the body, or null past the ceiling.
29
+ */
30
+ export async function readJsonBody(req) {
31
+ const chunks = []
32
+ let size = 0
33
+ for await (const chunk of req) {
34
+ size += chunk.byteLength
35
+ if (size > MAX_BODY_BYTES) {
36
+ // Drain the remainder so the refusal is a readable response, not a socket cut.
37
+ req.resume()
38
+ return { text: null, byteLength: size }
39
+ }
40
+ chunks.push(chunk)
41
+ }
42
+ return { text: Buffer.concat(chunks, size).toString('utf8'), byteLength: size }
43
+ }
44
+
45
+ /**
46
+ * Answer one request with a JSON envelope and an explicit status.
47
+ * @param {import('node:http').ServerResponse} res - the response to complete.
48
+ * @param {number} status - the HTTP status.
49
+ * @param {{ ok: boolean }} payload - the envelope, success or failure.
50
+ */
51
+ export function sendJson(res, status, payload) {
52
+ const body = JSON.stringify(payload)
53
+ res.writeHead(status, {
54
+ 'content-type': 'application/json; charset=utf-8',
55
+ 'content-length': Buffer.byteLength(body),
56
+ 'cache-control': 'no-store',
57
+ })
58
+ res.end(body)
59
+ }
60
+
61
+ /**
62
+ * Answer with one admission rejection.
63
+ * @param {import('node:http').ServerResponse} res - the response to complete.
64
+ * @param {import('../http-admission.js').Rejection} rejection - the refusal.
65
+ */
66
+ export function sendRejection(res, rejection) {
67
+ sendJson(res, rejection.status, { ok: false, code: rejection.code, error: rejection.message })
68
+ }
69
+
70
+ /** Trust surface consumed here; the browser-side connection package owns the full type. */
71
+ export function connectionOf(ctx) {
72
+ return Reflect.get(ctx, 'connection')
73
+ }
74
+
75
+ /**
76
+ * This installation's development token, minted on first use.
77
+ *
78
+ * The file is owner-only and lives outside the repository. A web page cannot
79
+ * read it, which is what keeps the acceptance surface unreachable from a
80
+ * cross-site request even though the route itself is on loopback.
81
+ * @returns {Promise<string>} the token.
82
+ */
83
+ export async function devToken() {
84
+ const path = join(dshHome(), 'wsl-desktop-dev-token')
85
+ try {
86
+ const existing = (await readFile(path, 'utf8')).trim()
87
+ if (existing.length > 0) return existing
88
+ // An empty file is unusual — treat it like absent and re-mint below.
89
+ } catch (error) {
90
+ // ENOENT = first use. Any other read failure (ACL, AV lock, disk) must
91
+ // fail loud rather than silently rotate the credential and revoke every
92
+ // scripted caller.
93
+ if (error?.code !== 'ENOENT') throw error
94
+ }
95
+ const minted = randomBytes(32).toString('hex')
96
+ await mkdir(dirname(path), { recursive: true })
97
+ await writeFile(path, `${minted}\n`, { mode: 0o600 })
98
+ return minted
99
+ }
100
+
101
+ /**
102
+ * Whether one request may use the acceptance surface.
103
+ * @param {import('node:http').IncomingMessage} req - the request.
104
+ * @param {{ developerTools: boolean }} config - resolved plugin config.
105
+ * @returns {Promise<boolean>} true for a caller presenting this installation's token.
106
+ */
107
+ export async function isDeveloperCaller(req, config) {
108
+ if (config.developerTools !== true) return false
109
+ return tokenMatches(req.headers[DEV_TOKEN_HEADER], await devToken(), timingSafeEqual)
110
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Preset derivation helpers: variant ids, base rows, and the shell-spec seam.
3
+ *
4
+ * Extracted verbatim from `lib/index.js` (C3). Only the PURE helpers moved: the
5
+ * two that materialise and refresh presets stay in the entry file because they
6
+ * read and write the plugin's module-level state (`presetState`, `currentGeneration`,
7
+ * `hostCtx`, `hostReady`). Moving those would mean shipping that state or injecting
8
+ * it, which is a rewrite, not a move — see the plan's "Out of Scope".
9
+ * @module dsh-wsl-desktop/host/presets
10
+ */
11
+
12
+ import { existsSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { dshHome } from './http.js'
16
+
17
+ /**
18
+ * The prefix every generated variant id carries.
19
+ *
20
+ * Not exported from `index.js` by accident: the same literal is what the route
21
+ * filters on, so a change here is a wire-visible change.
22
+ */
23
+ export const GENERATED_PREFIX = 'wsl-'
24
+
25
+ /**
26
+ * The stable id for one base preset's WSL variant.
27
+ * @param {{ id: string }} preset - the resolved base preset.
28
+ * @returns {string} the variant id.
29
+ */
30
+ export function variantIdFor(preset) {
31
+ return preset.id.startsWith(GENERATED_PREFIX) ? preset.id : `${GENERATED_PREFIX}${preset.id}`
32
+ }
33
+
34
+ /**
35
+ * Locate the directory a preset was discovered under.
36
+ *
37
+ * A preset may name a row by a path relative to its own directory, so the
38
+ * variant needs that base to rewrite the name to something that still resolves
39
+ * from where the variant lives.
40
+ *
41
+ * Both spellings are historical: the package is `@deepseek-ai/dsh-agent-preset`
42
+ * (singular — the plural name never existed, so this resolve always threw), and
43
+ * since the presets moved into profile YAML declarations the package ships no
44
+ * `presets/<id>/agent.cordis.yml` tree at all. Neither branch is expected to
45
+ * hit on a current desktop; a base preset that DOES use relative row names
46
+ * therefore has no resolvable source directory, and `buildVariantPlugins`
47
+ * leaves the name as it found it. This stays a lookup rather than a guess so
48
+ * the failure mode is unchanged if a deployment ever ships that tree again.
49
+ * @param {string} id - preset id.
50
+ * @returns {string | undefined} the directory, or undefined when it cannot be found.
51
+ */
52
+ export function sourceDirFor(id) {
53
+ const local = join(dshHome(), '.agent-presets', id)
54
+ if (existsSync(join(local, 'agent.cordis.yml'))) return local
55
+ try {
56
+ const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-agent-preset/package.json'))
57
+ const shipped = join(dirname(manifest), 'presets', id)
58
+ if (existsSync(join(shipped, 'agent.cordis.yml'))) return shipped
59
+ } catch {
60
+ // A deployment without the preset package leaves relative names as they are.
61
+ }
62
+ return undefined
63
+ }
64
+
65
+ /**
66
+ * Read a base preset's declared plugin rows as OBJECTS.
67
+ *
68
+ * 0.1.7 keeps each declaration's `PresetDefinition` — with `plugins` as entry
69
+ * objects — in a registry map that is TS-private but reachable in-process
70
+ * (ctx.get returns the service instance). No YAML parser is needed on this
71
+ * path; the public readDocument only exposes a re-dumped text form.
72
+ * @param {object} presets - the agentPresets service.
73
+ * @param {string} id - base preset id.
74
+ * @returns {readonly object[] | null} the declared rows, or null when unreachable.
75
+ */
76
+ export function basePluginsOf(presets, id) {
77
+ const plugins = presets.definitions?.get?.(id)?.config?.plugins
78
+ return Array.isArray(plugins) ? plugins : null
79
+ }
80
+
81
+ /**
82
+ * Run a shell spec through whichever seam the host exposes, and always hand back a
83
+ * handle whose `result()` is awaitable.
84
+ *
85
+ * 0.1.7 hosts expose `execute`, which resolves with a live handle; older ones have
86
+ * only `run`, whose settled result carries no `result()` projection. Both selftest
87
+ * steps used to spell that dual dispatch themselves, and the second one called
88
+ * `.result()` on `run`'s settled result — a TypeError on exactly the hosts that
89
+ * branch exists to support. One owner, so the two steps cannot drift again.
90
+ * @param {object} shell - the realm's shell service.
91
+ * @param {object} spec - the resolved spec to run.
92
+ * @returns {Promise<{ result: () => Promise<object> }>} a result()-able handle.
93
+ */
94
+ export function runShellSpec(shell, spec) {
95
+ if (typeof shell.execute === 'function') return shell.execute(spec)
96
+ return shell.run(spec).then((settled) => ({ result: async () => settled }))
97
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The plugin's module-level state, named and grouped.
3
+ *
4
+ * The route dispatcher calls `METHODS` without carrying the plugin's config, so
5
+ * the context, the configured `wsl.exe` path and the preset lifecycle cannot be
6
+ * parameters. They were loose module-level `let`s in `lib/index.js` (seven of them,
7
+ * counting the binding log and its cap); this module gives
8
+ * each group one owner, so a reader can see what is shared and why.
9
+ *
10
+ * Nothing here is a getter/setter pair: the objects are mutable, and every call
11
+ * site reads a field. That keeps the move mechanical — `hostCtx.get(x)` became
12
+ * `hostSession.ctx.get(x)`.
13
+ * @module dsh-wsl-desktop/host/state
14
+ */
15
+
16
+ /** The plugin context and the config it was applied with, as route methods see them. */
17
+ export const hostSession = {
18
+ /** @type {import('@deepseek-ai/cordis').Context | undefined} */
19
+ ctx: undefined,
20
+ /** The configured `wsl.exe` path; the default a fresh install runs with. */
21
+ wslPath: 'wsl.exe',
22
+ /** Whether the preset registry has been reached; `bootstrap` waits briefly for it. */
23
+ ready: false,
24
+ }
25
+
26
+ /** The preset registration lifecycle. */
27
+ export const presetRun = {
28
+ /** @type {{ status: string } & Record<string, unknown>} */
29
+ state: { status: 'pending' },
30
+ /** @type {{ disposed: boolean, disposers: Map<string, Function> } | null} */
31
+ generation: null,
32
+ /** Serialises refreshes so boot-time and developer-token calls cannot interleave. */
33
+ mutex: Promise.resolve(),
34
+ }
35
+
36
+ /** Most binding decisions kept for inspection. */
37
+ export const BINDING_LOG_LIMIT = 50
38
+
39
+ /** The bounded record of preset-binding decisions, reported through the route. */
40
+ export const bindingTape = (() => {
41
+ /** @type {object[]} */
42
+ const log = []
43
+ return {
44
+ log,
45
+ /**
46
+ * Record one decision, dropping the oldest past the cap.
47
+ *
48
+ * The log is captured by the closure rather than read off `this`: a caller that
49
+ * destructures the method (`const { record } = bindingTape`) would otherwise get
50
+ * `TypeError: this.log is undefined` at the first write.
51
+ * @param {object} entry - the decision to record.
52
+ */
53
+ record(entry) {
54
+ log.push(entry)
55
+ if (log.length > BINDING_LOG_LIMIT) log.splice(0, log.length - BINDING_LOG_LIMIT)
56
+ },
57
+ }
58
+ })()