fix(settings): use the host's @deepseek-ai/schemastery so volatile prefs apply
CI / ci (pull_request) Canceled after 0s
CI / ci-windows (pull_request) Canceled after 0s
CI / plugin-mount (pull_request) Canceled after 0s

Every side-card switch was dead: settings.update returned OK and
cordis.patch.yml was written, yet the running plugin never saw the new value
and nothing reported an error.

Root cause: the plugin declared Config with the plain npm `schemastery`
(3.18.0), which has no volatile support. The host resolves plugin config via
`runtime.Config['~standard'].validate()` — i.e. the plugin's OWN schemastery —
so `meta.volatile` nodes were never wrapped in cosmokit volatile references.
`cordis-plugin-loader`'s `_commitVolatile()` bails out as soon as
`volatileEntries(fiber.config)` is empty, so every volatile-only edit was
silently dropped: no in-place update, no plugin restart.

Fix: import z from '@deepseek-ai/schemastery' (peer provided by the host at
mount; pinned 3.18.4 as a devDependency for the dev tree), matching the
repo's existing pnpm-workspace convention for @deepseek-ai/* peers.

Verified: probe reports tabsIsVolatile=true and revision climbing 0->6 across
six writes, each read-back matching the written value (previously the value
was frozen and revision stuck at 1).

Also in this change:
- apes tab ids lose their colon (apes:tasks -> apesTasks, ...): the host's
  settings projection dropped map keys containing a colon.
- sidebar prefs ns follows the profile entry id (`better-sidebar`).
- subagent rows degrade to summary rows when the root catalog never hydrates.
- docs/feedback-0.2.0-rc2-contract-gaps.md: four host contract gaps, gap 4
  documenting the silent volatile-write drop above.

Gates: typecheck 0, lint 0, build 0, test 131 files / 1385 passed.
This commit is contained in:
figmar
2026-10-13 22:27:19 +08:00
parent 043340eb02
commit aa8f417aa3
11 changed files with 320 additions and 141 deletions
+90 -75
View File
@@ -74,7 +74,7 @@ const JOB_KILL_ARM_MS = 3000
* loading spinner — the session summaries are already in the mirror, so the
* tree degrades to clickable summary rows instead.
*/
const CATALOG_HYDRATE_TIMEOUT_MS = 5000
const CATALOG_HYDRATE_TIMEOUT_MS = 2500
/** The direct subagent children of one parent (durable `origin` rows;
* Side Chat threads ride the same origin but are tab-strip conversations,
@@ -160,72 +160,57 @@ function CatalogLoadingRows(props: {
}
/**
* Degrade rows rendered when the root catalog never hydrates within
* {@link CATALOG_HYDRATE_TIMEOUT_MS}: the session summaries are already in the
* mirror (`byId`), so instead of pinning the page on a permanent "loading…"
* spinner we render the known children as CLICKABLE rows that jump to the
* child session. The address `mode` is inferred from the summary's `running`
* flag (a running agent is continuable; a settled one is one-shot) — a
* best-effort transport hint, since the durable catalog `mode` is unavailable
* once hydration has given up.
* Synthesize a durable-catalog-shaped view from the session summaries already
* in the mirror, used ONLY as a fallback once the host never hydrates the real
* per-parent catalog (the `summaryBackedLoading` + timeout degrade). Each
* entry is built to the `SidebarSubagentChildEntry` contract so the OFFICIAL
* {@link CatalogRows} renderer can consume it unchanged — live lines, mode
* labels, diagnostic rows, nested branches and arrow-key navigation all come
* from the stock component, not a bespoke row. `mode` is a best-effort
* transport hint inferred from the summary's `running` flag (a running agent
* is continuable; a settled one is one-shot); `label` is left empty so
* {@link childLabel} falls back to the summary title.
*/
function SummaryFallbackRows(props: {
parentSessionId: string
byId: Readonly<Record<string, SidebarSessionSummary>>
level: number
currentSessionId: string
openChild: (address: SidebarSubagentAddress) => void
}) {
const { parentSessionId, byId, level, currentSessionId, openChild } = props
const children = directChildren(byId, parentSessionId)
if (children.length === 0) {
return <div className={css.subagentEmpty}>{t('loading')}</div>
}
return (
<>
{children.map(summary => {
const address: SidebarSubagentAddress = {
parentSessionId,
childSessionId: summary.id,
mode: summary.running === true ? 'continuable' : 'one-shot',
}
const current = summary.id === currentSessionId
const label = summary.displayTitle !== '' ? summary.displayTitle : summary.id
const secondary = [
summary.displayTitle,
summary.running === true ? t('subagentRunning') : t('subagentInactive'),
].filter(Boolean).join(' · ')
return (
<div
key={summary.id}
role="treeitem"
tabIndex={0}
aria-level={level}
aria-label={`${label} ${secondary}`}
aria-current={current ? 'true' : undefined}
className={clsx(css.subagentRow, current && css.subagentRowActive)}
onClick={() => { openChild(address) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openChild(address)
}
}}
>
<StateDot
state={summary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{label}</span>
<span className={css.subagentSecondary}>{secondary}</span>
</span>
</div>
)
})}
</>
function synthesizeCatalog(
byId: Readonly<Record<string, SidebarSessionSummary>>,
parentSessionId: string,
): SidebarSubagentCatalog {
const entries: SidebarSubagentChildEntry[] = directChildren(byId, parentSessionId).map(
(summary): SidebarSubagentChildEntry => ({
kind: 'child',
id: summary.id,
activity: summary.running === true ? 'running' : 'inactive',
hasChildren: directChildren(byId, summary.id).length > 0,
mode: summary.running === true ? 'continuable' : 'one-shot',
}),
)
return { entries, parentAvailable: true, state: 'ready', error: null }
}
/**
* Layer summary-synthesized catalogs for the root and (recursively) every
* descendant onto the host's `catalogs` map, so the stock {@link CatalogRows}
* never meets an `undefined` child catalog during the degrade — each branch
* expands into its synthesized children instead of a permanent "loading…".
* Real host catalogs always win; synthesis only fills the gaps.
*/
function mergeSynthesizedCatalogs(
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>,
byId: Readonly<Record<string, SidebarSessionSummary>>,
rootId: string,
): Readonly<Record<string, SidebarSubagentCatalog>> {
const merged: Record<string, SidebarSubagentCatalog> = { ...catalogs }
const seen = new Set<string>()
const visit = (parentSessionId: string): void => {
if (merged[parentSessionId] !== undefined || seen.has(parentSessionId)) return
seen.add(parentSessionId)
merged[parentSessionId] = synthesizeCatalog(byId, parentSessionId)
for (const entry of merged[parentSessionId].entries) {
if (entry.kind === 'child' && entry.hasChildren) visit(entry.id)
}
}
visit(rootId)
return merged
}
/**
@@ -780,7 +765,26 @@ export function SubagentView(props: {
// highlighted) — the README "page stays open" contract.
onOpenChild?.(address)
try {
sessions.openSubagent?.(address)
// Preferred: the host's exact direct-parent jump (keeps the child's
// own layout + the Tasks page re-open on top via `subagentJumpRef`).
// DSH 2.0.17 hosts that haven't exposed `sessions.openSubagent` fall
// through to a plain session open — the child still becomes the current
// session (so the jump-back highlight fires), the node is just reached
// without the host's topology-aware address. Without this fallback a
// missing `openSubagent` silently swallowed the click and the child
// was unreachable ("点不进去").
if (typeof sessions.openSubagent === 'function') {
sessions.openSubagent(address)
return
}
// `sessions.open(id)` selects a listed session as current (mirror of
// the runtime ISessions.open — the same mechanism the topology root
// node uses to jump back to the main agent). The child subagent session
// IS in `sessions.list` (the byId mirror), so selecting it lands the
// user on the child's own layout and the jump-back highlight fires.
if (typeof sessions.open === 'function') {
sessions.open(address.childSessionId)
}
} catch (error) {
console.error('[dsh-apes-sidebar] openSubagent failed:', error)
}
@@ -937,15 +941,6 @@ export function SubagentView(props: {
{summaryBackedLoading && !degraded && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{summaryBackedLoading && degraded && (
<SummaryFallbackRows
parentSessionId={rootId}
byId={byId}
level={1}
currentSessionId={sessionId}
openChild={openChild}
/>
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
@@ -959,6 +954,26 @@ export function SubagentView(props: {
refresh={refresh}
/>
)}
{/* Degraded (host never hydrated the root catalog): feed the
session-mirror-synthesized catalog into the STOCK CatalogRows
so the full official interaction (live lines, mode labels,
nested branches, arrow-key nav) is reused — not a bespoke
row. The synthesized root is merged into `catalogs` so any
branch CatalogRows recurses into also has its own
summary-synthesized catalog instead of a permanent loader. */}
{summaryBackedLoading && degraded && rootId !== undefined && (
<CatalogRows
parentSessionId={rootId}
catalog={synthesizeCatalog(byId, rootId)}
catalogs={mergeSynthesizedCatalogs(catalogs, byId, rootId)}
byId={byId}
level={1}
currentSessionId={sessionId}
live={live}
openChild={openChild}
refresh={refresh}
/>
)}
</div>
)}
{readyEmpty && (
+1 -1
View File
@@ -3,7 +3,7 @@
* upstream dsh-better-sidebar. Five tabs ship (tasks / agents / messages /
* apps / profile).
*
* History (C7): `apes:tasks` was removed under the old C6 decision (it looked
* History (C7): `apesTasks` was removed under the old C6 decision (it looked
* redundant next to the official DSH session tree). The `v2-revive` line
* restores it — the native tree is a *navigator* (one focused lineage), this
* panel is a *list* (every session, with an idle filter and a running badge),
+21 -21
View File
@@ -5,18 +5,18 @@
* live outside `builtins/`, so importing them does not couple the apes module
* to upstream UI and survives cherry-picks.
*
* Five information-layer tabs (C7 reversal: `apes:tasks` is back). The reason
* Five information-layer tabs (C7 reversal: `apesTasks` is back). The reason
* the earlier C6 removal no longer holds: the native DSH session tree renders
* ONE focused lineage, while this panel is a *flat, whole-workspace* list with
* a running badge and an idle filter — complementary, not redundant.
* - apes:tasks → every session (running / current / subagent marks) with
* - apesTasks → every session (running / current / subagent marks) with
* an idle filter; a running count badges the tab.
* - apes:agents → the subagent topology rooted at the main agent of the
* - apesAgents → the subagent topology rooted at the main agent of the
* current session (rootAncestor + per-parent catalogs).
* - apes:messages → honest placeholder: DSH exposes no message-stream feed.
* - apes:apps → no DSH host feed today: shows the workspace (cwd) of the
* - apesMessages → honest placeholder: DSH exposes no message-stream feed.
* - apesApps → no DSH host feed today: shows the workspace (cwd) of the
* current session, otherwise an honest "no feed" note.
* - apes:profile → the current session's summary row (title, cwd, running).
* - apesProfile → the current session's summary row (title, cwd, running).
*
* Live-data discipline (C4): list-reading views subscribe only while the tab
* is on screen (`visible ? subscribe : NOOP_SUBSCRIBE`), so a collapsed panel
@@ -85,10 +85,10 @@ const S: Record<string, CSSProperties> = {
taskRow: { appearance: 'none', textAlign: 'left', width: '100%', cursor: 'pointer', font: 'inherit', color: 'inherit' },
}
/** apes:tasks descriptor id (the plugin-toggle blob lives under this key). */
export const TASKS_TAB_ID = 'apes:tasks'
/** apesTasks descriptor id (the plugin-toggle blob lives under this key). */
export const TASKS_TAB_ID = 'apesTasks'
/** The plugin-local switch persisted in `pluginSettings['apes:tasks']`. */
/** The plugin-local switch persisted in `pluginSettings['apesTasks']`. */
export const TASKS_SHOW_INACTIVE_KEY = 'showInactive'
/** A small colored status dot (self-contained, not the builtin StateDot). */
@@ -135,7 +135,7 @@ function useSessionList(ctx: TabComponentProps['ctx'], visible = true): SidebarS
/**
* The `showInactive` filter state. The declarative settings row writes it into
* the sidebar prefs document under `pluginSettings['apes:tasks']` (service
* the sidebar prefs document under `pluginSettings['apesTasks']` (service
* contract, `SidebarSettingsDeclaration.pluginToggles`), which is NOT part of
* the public `SidebarPrefs` type — hence the narrow local cast. Default is
* true: the panel is a task list, so showing idle sessions is the useful
@@ -153,7 +153,7 @@ export function tasksShowInactive(store: TabComponentProps['store']): boolean {
}
/**
* apes:tasks rows: every non-side-chat session, running ones first (then the
* apesTasks rows: every non-side-chat session, running ones first (then the
* current one), idle ones only when `showInactive`. PURE — unit-tested.
*/
export function tasksRows(list: SidebarSessionList, showInactive: boolean): SidebarSessionSummary[] {
@@ -194,7 +194,7 @@ function agentChildren(
)
}
/** apes:tasks — the flat, whole-workspace session list. */
/** apesTasks — the flat, whole-workspace session list. */
export function ApesTasksView(props: TabComponentProps) {
const list = useSessionList(props.ctx, props.visible)
const store = props.store
@@ -244,7 +244,7 @@ export function ApesTasksView(props: TabComponentProps) {
)
}
/** apes:messages — DSH exposes no message-stream feed; honest placeholder. */
/** apesMessages — DSH exposes no message-stream feed; honest placeholder. */
export function ApesMessagesView(_props: TabComponentProps) {
return (
<div style={S.body}>
@@ -254,7 +254,7 @@ export function ApesMessagesView(_props: TabComponentProps) {
)
}
/** apes:agents — the subagent topology of the current session's tree. */
/** apesAgents — the subagent topology of the current session's tree. */
function ApesAgentsView(props: TabComponentProps) {
const list = useSessionList(props.ctx, props.visible)
const { byId, subagentsByParent } = list
@@ -322,7 +322,7 @@ function ApesAgentsView(props: TabComponentProps) {
)
}
/** apes:profile — the current session's identity (title, cwd, running state). */
/** apesProfile — the current session's identity (title, cwd, running state). */
export function ApesProfileView(props: TabComponentProps) {
const list = useSessionList(props.ctx, props.visible)
const current = list.current
@@ -359,7 +359,7 @@ export function ApesProfileView(props: TabComponentProps) {
)
}
/** apes:apps — no DSH host feed today; shows the current workspace (cwd). */
/** apesApps — no DSH host feed today; shows the current workspace (cwd). */
function ApesAppsView(props: TabComponentProps) {
const list = useSessionList(props.ctx, props.visible)
const current = list.current
@@ -383,7 +383,7 @@ function ApesAppsView(props: TabComponentProps) {
}
/**
* The five apes:* tab descriptors (C7: `apes:tasks` restored — the native
* The five apes:* tab descriptors (C7: `apesTasks` restored — the native
* session tree focuses one lineage, this list covers the whole workspace).
* Order 55-70 keeps them after the built-ins (editor 10 … browser 50) and in
* reading order: tasks / agents / apps / messages / profile. Each is already
@@ -410,7 +410,7 @@ export const apesTabs: TabDescriptor[] = [
},
},
{
id: 'apes:agents',
id: 'apesAgents',
title: () => t('apesAgentsTitle'),
icon: (size: number) => <IconAgentOutline16 size={size} />,
order: 60,
@@ -421,7 +421,7 @@ export const apesTabs: TabDescriptor[] = [
return count > 0 ? count : null
},
},
{ id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
{ id: 'apes:messages', title: () => t('apesMessagesTitle'), icon: (size: number) => <IconMessagesOutline16 size={size} />, order: 67, single: true, component: ApesMessagesView },
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
{ id: 'apesApps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
{ id: 'apesMessages', title: () => t('apesMessagesTitle'), icon: (size: number) => <IconMessagesOutline16 size={size} />, order: 67, single: true, component: ApesMessagesView },
{ id: 'apesProfile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
]