Fix preset publication, volatile settings and client form binding

This commit is contained in:
oliver 2026-10-11 12:54:02 +08:00
parent fe0fe70d33
commit 0b9a3a82bc
66 changed files with 8546 additions and 3690 deletions

View file

@ -2,7 +2,7 @@
* Install Netx Ops agent preset for both DSH host styles:
*
* - DSH ≥0.2 (`@deepseek-ai/dsh-agent-preset` + registry): declarative insert
* row synced into **desktop and web** profile `cordis.patch.yml` on apply.
* row synced into the **running** profile's `cordis.patch.yml` on apply.
* Directory `$DSH_HOME/.agent-presets/netxops` is still written for older
* hosts that scan `@deepseek-ai/dsh-agent-presets`, but 0.2 web/desktop no
* longer load that roster — without the patch row, Settings → Agent presets
@ -19,15 +19,14 @@ import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import { ensureNetxopsHostInProfilePatches } from './netx/heal-profile-entry.ts'
export const NETXOPS_PRESET_ID = 'netxops'
@ -47,6 +46,17 @@ function resolveDshHome(): string {
return join(homedir(), '.dsh')
}
/** Current Host profile; installing one Host never edits a second Host's patch. */
export function resolveActiveProfile(baseUrl = ''): string {
const declared = process.env.DSH_PROFILE?.trim()
if (declared && /^[\w-]+$/.test(declared)) return declared
const fromBase = /[\\/]profiles[\\/]([\w-]+)[\\/]/.exec(baseUrl)?.[1]
if (fromBase) return fromBase
const flag = process.argv.indexOf('--profile')
if (flag >= 0 && /^[\w-]+$/.test(process.argv[flag + 1] ?? '')) return process.argv[flag + 1]
return process.versions.electron ? 'desktop' : 'web'
}
function electronResourcesRoots(): string[] {
const roots: string[] = []
const rp = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
@ -70,13 +80,13 @@ function readableFile(path: string): boolean {
* Prefer Electron `app.asar` (what the running Desktop actually loads) over
* `$DSH_HOME/profiles/node_modules` (often the older CLI heal tree).
*/
export function resolveDesktopStandardPatchPath(): string | null {
export function resolveDesktopStandardPatchPath(profile = resolveActiveProfile()): string | null {
const dshHome = resolveDshHome()
const envOverride = process.env.NETXOPS_STANDARD_PATCH?.trim()
const candidates: string[] = []
if (envOverride) candidates.push(envOverride)
for (const resources of electronResourcesRoots()) {
for (const resources of profile === 'desktop' ? electronResourcesRoots() : []) {
candidates.push(
join(resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml'),
join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml'),
@ -84,11 +94,10 @@ export function resolveDesktopStandardPatchPath(): string | null {
}
const requireAnchors = [
join(dshHome, 'profiles', 'desktop', 'package.json'),
join(dshHome, 'profiles', 'desktop', 'node_modules', 'dsh-netxops', 'package.json'),
join(dshHome, 'profiles', profile, 'package.json'),
join(dshHome, 'profiles', profile, 'node_modules', 'dsh-netxops', 'package.json'),
join(packageRoot(), 'package.json'),
join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'package.json'),
join(dshHome, 'profiles', 'web', 'package.json'),
]
for (const anchor of requireAnchors) {
if (!existsSync(anchor)) continue
@ -117,11 +126,10 @@ export function resolveDesktopStandardPatchPath(): string | null {
* Profile-local copies win over the shared `$DSH_HOME/profiles/node_modules`
* heal tree, which is frequently an older CLI generation than Desktop.
*/
export function resolveStandardPresetDir(): string | null {
export function resolveStandardPresetDir(profile = resolveActiveProfile()): string | null {
const dshHome = resolveDshHome()
const candidates = [
join(dshHome, 'profiles', 'desktop', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
join(dshHome, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
join(dshHome, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'),
// Source checkout used by `dsh` CLI / local Harness.
join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'DeepSeekHarness', 'packages', 'preset', 'agent-presets', 'presets', 'standard'),
// Shared heal tree last — often lags Desktop 0.2.
@ -131,8 +139,7 @@ export function resolveStandardPresetDir(): string | null {
if (existsSync(join(dir, 'agent.cordis.yml'))) return dir
}
for (const nm of [
join(dshHome, 'profiles', 'desktop', 'node_modules'),
join(dshHome, 'profiles', 'web', 'node_modules'),
join(dshHome, 'profiles', profile, 'node_modules'),
join(dshHome, 'profiles', 'node_modules'),
]) {
const pkgJson = join(nm, '@deepseek-ai', 'dsh-agent-presets', 'package.json')
@ -173,23 +180,21 @@ export function desktopRuntimeLikelyPresent(): boolean {
}
/** Host roots used for bare package health checks (same walk shape as DSH discovery). */
export function hostResolveBases(): string[] {
export function hostResolveBases(profile = resolveActiveProfile()): string[] {
const dshHome = resolveDshHome()
const bases: string[] = []
for (const resources of electronResourcesRoots()) {
for (const resources of profile === 'desktop' ? electronResourcesRoots() : []) {
bases.push(join(resources, 'app.asar', 'dsh', 'package.json'))
bases.push(join(resources, 'app.asar', 'dsh', 'node_modules'))
}
const localAppData = process.env.LOCALAPPDATA?.trim()
if (localAppData) {
if (localAppData && profile === 'desktop') {
const asarDsh = join(localAppData, 'Programs', 'DeepSeek Harness', 'resources', 'app.asar', 'dsh')
bases.push(join(asarDsh, 'package.json'), join(asarDsh, 'node_modules'))
}
bases.push(
join(dshHome, 'profiles', 'desktop', 'package.json'),
join(dshHome, 'profiles', 'desktop', 'node_modules'),
join(dshHome, 'profiles', 'web', 'package.json'),
join(dshHome, 'profiles', 'web', 'node_modules'),
join(dshHome, 'profiles', profile, 'package.json'),
join(dshHome, 'profiles', profile, 'node_modules'),
join(dshHome, 'profiles', 'node_modules'),
join(packageRoot(), 'package.json'),
)
@ -389,7 +394,7 @@ export interface NetxopsPresetArtifacts {
/** Human-readable source path (directory or patch file). */
standardSource: string
/** Kind of host standard used for composition. */
standardKind: 'desktop-patch' | 'directory'
standardKind: 'desktop-patch' | 'directory' | 'host-registry'
/** Directory to seed `.agent-presets/netxops` from; null when patch-only. */
standardDir: string | null
name: string
@ -410,12 +415,8 @@ function readPresetMeta(metaPath: string): { name: string; description: string;
}
}
function desktopProfilePresent(): boolean {
return existsSync(join(resolveDshHome(), 'profiles', 'desktop'))
}
/** Compose standard + Netx Ops overlays into directory + declarative artifacts. */
export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
export function composeNetxopsPresetArtifacts(profile = resolveActiveProfile(), standardDocument?: string): NetxopsPresetArtifacts {
const overlayRoot = join(packageRoot(), 'presets', NETXOPS_PRESET_ID)
const personaPath = join(overlayRoot, 'PERSONA.md')
const metaPath = join(overlayRoot, 'preset.yml')
@ -423,29 +424,35 @@ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
throw new Error(`bundled preset overlay missing under ${overlayRoot}`)
}
const meta = readPresetMeta(metaPath)
const bases = hostResolveBases()
const patchPath = resolveDesktopStandardPatchPath()
const bases = hostResolveBases(profile)
const patchPath = standardDocument === undefined ? resolveDesktopStandardPatchPath(profile) : null
const usePatch = patchPath !== null && (
process.env.NETXOPS_PREFER_DESKTOP_PATCH !== '0'
) && (
typeof process.versions.electron === 'string'
|| process.env.NETXOPS_PREFER_DESKTOP_PATCH === '1'
|| desktopProfilePresent()
|| desktopRuntimeLikelyPresent()
|| profile === 'desktop'
|| resolveStandardPresetDir(profile) === null
)
let standardSource: string
let standardKind: 'desktop-patch' | 'directory'
let standardKind: NetxopsPresetArtifacts['standardKind']
let standardDir: string | null
let agentCordis: string
if (usePatch && patchPath !== null) {
if (standardDocument !== undefined) {
if (!standardDocument.trim()) throw new Error('Host standard preset composition is empty')
standardSource = `Host ${profile}: standard preset`
standardKind = 'host-registry'
standardDir = null
agentCordis = standardDocument
} else if (usePatch && patchPath !== null) {
standardSource = patchPath
standardKind = 'desktop-patch'
standardDir = null
agentCordis = pluginsBodyToAgentCordis(extractPluginsBodyFromStandardPatch(readFileSync(patchPath, 'utf8')))
} else {
const dir = resolveStandardPresetDir()
const dir = resolveStandardPresetDir(profile)
if (dir === null) {
throw new Error(
'cannot find host standard preset (Desktop standard.patch.yml or @deepseek-ai/dsh-agent-presets)',
@ -463,14 +470,18 @@ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts {
personaEntryFromMarkdown(readFileSync(personaPath, 'utf8')),
)
agentCordis = ensureNetxopsToolsRow(agentCordis)
agentCordis = alignWorkflowEngineForHost(agentCordis, bases)
if (standardDocument === undefined) {
agentCordis = alignWorkflowEngineForHost(agentCordis, bases, profile === 'desktop' ? 'desktop' : 'web')
}
if (!agentCordis.endsWith('\n')) agentCordis += '\n'
const missing = unresolvableCompositionPackages(agentCordis, bases)
// A declaration read from the running Host preserves its Loader dialect and
// package resolution. Filesystem anchors may point at a different checkout.
const missing = (standardDocument === undefined ? unresolvableCompositionPackages(agentCordis, bases) : [])
.filter((name) => !name.startsWith('dsh-netxops'))
// Packaged Desktop keeps most `@deepseek-ai/*` only inside app.asar; plain
// Node cannot probe them, but the Host can. Do not block the write for those.
const desktopAsar = desktopRuntimeLikelyPresent()
const desktopAsar = profile === 'desktop' && desktopRuntimeLikelyPresent()
const blocking = missing.filter((name) => !(desktopAsar && name.startsWith('@deepseek-ai/')))
if (blocking.length > 0) {
throw new Error(
@ -498,31 +509,47 @@ ${pluginsBody}
return { standardSource, standardKind, standardDir, ...meta, agentCordis, declarativePatch }
}
function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Context['logger']): void {
export function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Context['logger']): void {
const overlayRoot = join(packageRoot(), 'presets', NETXOPS_PRESET_ID)
const destParent = join(resolveDshHome(), '.agent-presets')
const dest = join(destParent, NETXOPS_PRESET_ID)
mkdirSync(destParent, { recursive: true })
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
const marker = join(dest, '.dsh-netxops-managed')
if (existsSync(dest) && !existsSync(marker)) {
logger.warn('netxops: preserving user-managed preset at %s', dest)
return
}
const existed = existsSync(dest)
mkdirSync(dest, { recursive: true })
if (artifacts.standardDir !== null && existsSync(artifacts.standardDir)) {
if (!existed && artifacts.standardDir !== null && existsSync(artifacts.standardDir)) {
cpSync(artifacts.standardDir, dest, { recursive: true })
}
writeFileSync(join(dest, 'preset.yml'), readFileSync(join(overlayRoot, 'preset.yml'), 'utf8'))
writeFileSync(join(dest, 'PERSONA.md'), readFileSync(join(overlayRoot, 'PERSONA.md'), 'utf8'))
writeFileSync(join(dest, 'agent.cordis.yml'), artifacts.agentCordis)
const skillsSrc = join(overlayRoot, 'skills')
if (existsSync(skillsSrc)) cpSync(skillsSrc, join(dest, 'skills'), { recursive: true })
writeFileSync(
join(dest, '.dsh-netxops-managed'),
[
`composed-from: ${artifacts.standardKind}`,
`standard-source: ${artifacts.standardSource}`,
`at: ${new Date().toISOString()}`,
'',
].join('\n'),
'utf8',
)
let hashes: Record<string, string> = {}
if (existsSync(marker)) {
try { hashes = JSON.parse(readFileSync(marker, 'utf8')).files ?? {} }
catch { /* Legacy marker: preserve differing files on the first upgrade. */ }
}
const hash = (text: string): string => createHash('sha256').update(text).digest('hex')
const files = {
'preset.yml': readFileSync(join(overlayRoot, 'preset.yml'), 'utf8'),
'PERSONA.md': readFileSync(join(overlayRoot, 'PERSONA.md'), 'utf8'),
'agent.cordis.yml': artifacts.agentCordis,
}
for (const [name, content] of Object.entries(files)) {
const path = join(dest, name)
if (existed && existsSync(path)) {
const current = readFileSync(path, 'utf8')
if (current === content) { hashes[name] = hash(content); continue }
if (hash(current) !== hashes[name]) {
logger.warn('netxops: preserving edited preset file %s', path)
continue
}
}
writeFileSync(path, content, 'utf8')
hashes[name] = hash(content)
}
// Skills are published exclusively by the runtime policy, rather than copied into auto-scanned roots.
writeFileSync(marker, `${JSON.stringify({ standardSource: artifacts.standardSource, files: hashes }, null, 2)}\n`, 'utf8')
logger.info(
'netxops: directory preset composed from %s → %s',
artifacts.standardSource,
@ -536,7 +563,7 @@ function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Conte
* Both `desktop` and `web` need the `@deepseek-ai/dsh-agent-preset` insert;
* directory `.agent-presets` alone is invisible on 0.2.
*/
function syncDeclarativeIntoProfilePatch(
export function syncDeclarativeIntoProfilePatch(
profileName: string,
declarativePatch: string,
logger: Context['logger'],
@ -546,16 +573,26 @@ function syncDeclarativeIntoProfilePatch(
const patchPath = join(profileDir, 'cordis.patch.yml')
if (!existsSync(profileDir)) return
let patch = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '[]\n'
const original = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '[]\n'
let patch = original
// Drop previous managed blocks (literal markers — do not RegExp `(managed)`).
patch = stripManagedPresetBlocks(patch)
// Drop a bare enable stub left by older installs.
patch = patch.replace(/^- id: preset-netxops\n(?: .*\n)*/m, '')
if (/^\s*- id: preset-netxops\s*$/m.test(patch)) {
logger.warn('netxops: preserving user-declared preset-netxops in %s', patchPath)
return
}
if (patch.trim() === '' || patch.trim() === '[]') patch = ''
if (!patch.endsWith('\n') && patch.length > 0) patch += '\n'
const managed = `${DESKTOP_PATCH_BEGIN}\n${declarativePatch.trimEnd()}\n${DESKTOP_PATCH_END}\n`
writeFileSync(patchPath, `${patch}${managed}`, 'utf8')
const next = `${patch}${managed}`
if (next === original) return
if (existsSync(patchPath) && readFileSync(patchPath, 'utf8') !== original) {
logger.warn('netxops: profile patch changed during preset installation; preserving %s', patchPath)
return
}
writeFileSync(patchPath, next, 'utf8')
logger.info(
'netxops: declarative preset synced into profiles/%s/cordis.patch.yml (restart %s to load)',
profileName,
@ -571,8 +608,7 @@ function stripManagedPresetBlocks(patch: string): string {
if (start < 0) break
const end = out.indexOf(DESKTOP_PATCH_END, start)
if (end < 0) {
out = out.slice(0, start)
break
throw new Error('unterminated managed preset block; preserving profile patch')
}
let cut = end + DESKTOP_PATCH_END.length
if (out[cut] === '\r') cut += 1
@ -585,10 +621,10 @@ function stripManagedPresetBlocks(patch: string): string {
/**
* Build directory preset (web) + sync declarative patch into Desktop profiles.
*/
export function ensureAgentPresetInstalled(logger: Context['logger']): void {
export function ensureAgentPresetInstalled(logger: Context['logger'], profile = resolveActiveProfile(), standardDocument?: string): void {
let artifacts: NetxopsPresetArtifacts
try {
artifacts = composeNetxopsPresetArtifacts()
artifacts = composeNetxopsPresetArtifacts(profile, standardDocument)
} catch (error) {
logger.warn('netxops: skip preset install: %s', error)
return
@ -599,24 +635,8 @@ export function ensureAgentPresetInstalled(logger: Context['logger']): void {
logger.error('netxops: failed to install directory preset: %s', error)
}
try {
// Refresh shipped declarative file inside the package for next bundle consumers.
writeFileSync(join(packageRoot(), 'presets', 'netxops.preset.patch.yml'), artifacts.declarativePatch)
} catch {
// package may be read-only in some installs
}
try {
for (const profile of ['desktop', 'web']) {
syncDeclarativeIntoProfilePatch(profile, artifacts.declarativePatch, logger)
}
syncDeclarativeIntoProfilePatch(profile, artifacts.declarativePatch, logger)
} catch (error) {
logger.error('netxops: failed to sync declarative preset into profile patch: %s', error)
}
try {
// (1) Sparse `- id: netxops` from Plugin Manager → expand missing config keys.
// (2) Missing host row after dirty reinstall (preset channel self-healed,
// bundle host insert lost) → insert full `name: dsh-netxops` block.
ensureNetxopsHostInProfilePatches(logger)
} catch (error) {
logger.error('netxops: failed to heal host profile entry: %s', error)
}
}

View file

@ -20,9 +20,12 @@ import {
} from '../netx/im-targets.ts'
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
import { kbStatusTone, type KbSnapshot } from './kb-status-view.ts'
import { toolsMountTone, type ToolsMountView } from './tools-status-view.ts'
import { ensureStyles } from './styles.ts'
import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts'
import { parsePresetIds, TOOLS_BY_GROUP } from '../netx/capability-groups.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey {
switch (phase) {
@ -78,6 +81,24 @@ function fillTemplate(template: string, vars: Record<string, string>): string {
))
}
function toolsBadgeLabel(
t: (key: NetxopsLocaleKey) => string,
view: ToolsMountView | null,
): string {
if (!view) return t('toolsStatusUnknown')
if (!view.toolsEnabled) return t('toolsDisabled')
if (view.error) {
return fillTemplate(t('toolsStatusError'), { detail: view.error })
}
if (view.count > 0) {
return fillTemplate(t('toolsStatusMounted'), {
count: String(view.count),
version: view.version || '?',
})
}
return t('toolsStatusEmpty')
}
function exportStatusMessage(
t: (key: NetxopsLocaleKey) => string,
status: NonNullable<NetxopsCardState['sessionsExportStatus']>,
@ -108,7 +129,7 @@ function exportErrorMessage(
}
export type NetxopsCardProps =
PropsRuntime<'settings.section'>
(PropsRuntime<'settings.section'> | PropsRuntime<'settings.plugin.item'>)
& PropsLocale<'settings.netxops'>
& InjectFace<NetxopsCardFace>
@ -309,6 +330,35 @@ function ImDeliveryPicker(props: {
)
}
function PresetTargets(props: {
field: CardFieldState
state: NetxopsCardState
disabled: boolean
onEdit: (value: string) => void
t: (key: NetxopsLocaleKey) => string
}) {
const selected = parsePresetIds(props.field.text)
const rows = props.state.presetCatalog.filter(row => row.id !== 'netxops')
const unknown = selected.filter(id => !rows.some(row => row.id === id))
return (
<details className="dsh-nx-targets">
<summary>{props.t('selectedPresets')} · {selected.length}</summary>
<p className="dsh-nx-hint">{props.t('publicationHint')}</p>
{props.state.presetCatalogError ? <p className="dsh-nx-invalid">{props.t('presetCatalogUnavailable')}</p> : null}
{[...rows, ...unknown.map(id => ({ id, name: undefined, broken: undefined }))].map(row => (
<label key={row.id} className="dsh-nx-checkRow">
<input type="checkbox" checked={selected.includes(row.id)} disabled={props.disabled}
onChange={event => { props.onEdit((event.target.checked
? [...selected, row.id] : selected.filter(id => id !== row.id)).join(',')) }} />
<span>{row.name || row.id}{row.name && row.name !== row.id ? ` (${row.id})` : ''}</span>
{row.broken ? <span className="dsh-nx-invalid" title={row.broken}>{props.t('presetUnavailable')}</span> : null}
</label>
))}
{rows.length === 0 && unknown.length === 0 ? <p className="dsh-nx-hint">{props.t('noOtherPresets')}</p> : null}
</details>
)
}
function CapabilityGroupBlock(props: {
title: string
inPresetLabel: string
@ -318,6 +368,7 @@ function CapabilityGroupBlock(props: {
disabled: boolean
onEditInPreset: (checked: boolean) => void
onEditPublic: (checked: boolean) => void
targets?: ReactNode
}) {
return (
<div className="dsh-nx-groupBlock">
@ -342,6 +393,7 @@ function CapabilityGroupBlock(props: {
<span>{props.publicLabel}</span>
</label>
</div>
{props.targets}
</div>
)
}
@ -419,7 +471,8 @@ export function NetxopsCard(props: NetxopsCardProps) {
if (!state.available) {
return (
<section className="dsh-nx-settings" aria-label={t('title')}>
<div className="dsh-nx-settings-empty">{t('sessionsExportUnavailable')}</div>
<h2>{t('title')}</h2>
<div className="dsh-nx-settings-empty" role="status">{t('settingsUnavailable')}</div>
</section>
)
}
@ -427,6 +480,11 @@ export function NetxopsCard(props: NetxopsCardProps) {
const disabled = !state.writable
const blocked = !state.dirty || state.invalid || state.saving
const pushStatus = state.alarmPushStatus
const publicationDisabled = disabled || state.toolsEnabled.text !== 'true'
const targets = (field: string, value: CardFieldState) => (
<PresetTargets field={value} state={state} disabled={publicationDisabled} t={t}
onEdit={text => { props.edit(field, text) }} />
)
return (
<section className="dsh-nx-settings" aria-label={t('title')}>
@ -441,6 +499,25 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-settings-card">
<h3>{t('sectionConnection')}</h3>
<div className="dsh-nx-settings-field">
<div className="dsh-nx-field-head">
<span>{t('toolsStatus')}</span>
<StatusBadge
tone={toolsMountTone(state.toolsStatus)}
label={toolsBadgeLabel(t, state.toolsStatus)}
/>
</div>
<p className="dsh-nx-hint">{t('mountStatusHint')}</p>
{state.toolsStatus && state.toolsStatus.mounts.length > 0 ? (
<details><summary>{t('mountDetails')}</summary>
{state.toolsStatus.mounts.map((mount, index) => (
<p key={`${mount.source}:${index}`} className={mount.error ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
{mount.source} · {mount.count} · {mount.groups.join(', ') || '—'}{mount.error ? ` · ${mount.error}` : ''}
</p>
))}
</details>
) : null}
</div>
<div className="dsh-nx-settings-field">
<div className="dsh-nx-field-head">
<label htmlFor="netxops-api-token">{t('apiToken')}</label>
@ -469,7 +546,7 @@ export function NetxopsCard(props: NetxopsCardProps) {
field={state.apiUrl}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
invalidLabel={t('apiUrlInvalid')}
disabled={disabled}
onEdit={(text) => { props.edit('apiUrl', text) }}
onReset={() => { props.resetField('apiUrl') }}
@ -536,13 +613,20 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-settings-card">
<h3>{t('sectionCapabilities')}</h3>
<ToggleField id="netxops-tools-enabled" label={t('toolsEnabled')}
checked={state.toolsEnabled.text === 'true'} disabled={disabled}
overridden={state.toolsEnabled.overridden} overriddenLabel={t('overridden')} resetLabel={t('reset')}
onToggle={checked => { props.edit('toolsEnabled', String(checked)) }}
onReset={() => { props.resetField('toolsEnabled') }}
hint={<p className="dsh-nx-hint">{t('toolsEnabledHint')}</p>} />
<CapabilityGroupBlock
title={t('groupOps')}
title={`${t('groupOps')} · ${TOOLS_BY_GROUP.ops.length}`}
targets={targets('groupOpsPresets', state.groupOpsPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupOpsInPreset}
published={state.groupOpsPublic}
disabled={disabled}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupOpsInPreset', checked ? 'true' : 'false')
}}
@ -551,12 +635,13 @@ export function NetxopsCard(props: NetxopsCardProps) {
}}
/>
<CapabilityGroupBlock
title={t('groupTopology')}
title={`${t('groupTopology')} · ${TOOLS_BY_GROUP.topology.length}`}
targets={targets('groupTopologyPresets', state.groupTopologyPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupTopologyInPreset}
published={state.groupTopologyPublic}
disabled={disabled}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
}}
@ -565,12 +650,13 @@ export function NetxopsCard(props: NetxopsCardProps) {
}}
/>
<CapabilityGroupBlock
title={t('groupBizMonitor')}
title={`${t('groupBizMonitor')} · ${TOOLS_BY_GROUP.bizMonitor.length}`}
targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupBizMonitorInPreset}
published={state.groupBizMonitorPublic}
disabled={disabled}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false')
}}
@ -650,11 +736,12 @@ export function NetxopsCard(props: NetxopsCardProps) {
</div>
<CapabilityGroupBlock
title={t('groupKb')}
targets={targets('groupKbPresets', state.groupKbPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupKbInPreset}
published={state.groupKbPublic}
disabled={disabled}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupKbInPreset', checked ? 'true' : 'false')
}}

View file

@ -3,6 +3,8 @@
* Self-contained (cannot value-import ui-settings-plugins — client bundle purity).
*/
import { normalizeApiUrl } from '../netx/http.ts'
import { parsePresetIds } from '../netx/capability-groups.ts'
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
import { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts'
@ -62,6 +64,33 @@ export function textField(field: string): CardFieldSpec {
}
}
/** Empty selection must override a profile's target list rather than restore it. */
export function presetIdsField(field: string): CardFieldSpec {
return {
field,
format: value => typeof value === 'string' ? value : '',
parse: text => ({ kind: 'set', value: parsePresetIds(text).join(',') }),
}
}
/**
* http(s) API root — rejects credentials-in-URL and non-http schemes.
* Empty clears to schema default; save stores the normalized URL.
*/
export function apiUrlField(field: string): CardFieldSpec {
return {
field,
format: value => typeof value === 'string' ? value : '',
parse: (text) => {
const trimmed = text.trim()
if (trimmed === '') return { kind: 'clear' }
const normalized = normalizeApiUrl(trimmed)
if (!normalized.ok) return undefined
return { kind: 'set', value: normalized.url }
},
}
}
/** Boolean settings field rendered as a checkbox (stores true / clears when false). */
export function booleanField(field: string): CardFieldSpec {
return {
@ -108,6 +137,7 @@ export class CardForm<T> {
private readonly listeners = new Set<() => void>()
private saving = false
private failed = false
private saveQueue: Promise<void> = Promise.resolve()
constructor(
private readonly scope: SettingsFormScope<T>,
@ -128,11 +158,18 @@ export class CardForm<T> {
shell(): CardShell {
const snapshot = this.scope.getSnapshot()
const plan = this.plan()
let committedInvalid = false
for (const field of this.specs.keys()) {
if (this.field(field).invalid) {
committedInvalid = true
break
}
}
return {
available: snapshot.status === 'ready',
writable: snapshot.writable,
dirty: plan.length > 0,
invalid: plan.some(item => item.run === undefined),
invalid: committedInvalid || plan.some(item => item.run === undefined),
saving: this.saving,
failed: this.failed,
}
@ -145,7 +182,15 @@ export class CardForm<T> {
}
const spec = this.spec(field)
if (staged === undefined) {
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
const text = spec.format(this.sectionValue(field))
// Surface committed bad values (e.g. file:// apiUrl) so Save stays blocked
// until the operator fixes them — not only while the field is staged.
const write = text.trim() === '' ? { kind: 'clear' as const } : spec.parse(text)
return {
text,
overridden: this.stored(field),
invalid: text.trim() !== '' && write === undefined,
}
}
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
return {
@ -171,21 +216,43 @@ export class CardForm<T> {
}
}
async save(): Promise<void> {
const plan = this.plan()
/** Serialize writes and save only requested fields for immediate publication toggles. */
save(fields?: readonly string[]): Promise<void> {
const task = this.saveQueue.then(() => this.saveNow(fields))
this.saveQueue = task.catch(() => {})
return task
}
private async saveNow(fields?: readonly string[]): Promise<void> {
const snapshot = this.snapshotOf()
if (snapshot.status !== 'ready' || !snapshot.writable) {
this.failed = true
this.publish()
return
}
const staged = new Map([...this.staged].filter(([field]) => !fields || fields.includes(field)))
const plan = this.plan().filter(item => !fields || fields.includes(item.field))
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
if (writes.length !== plan.length) return
this.saving = true
this.failed = false
this.publish()
let landed = true
for (const write of writes) {
landed = await write() && landed
try {
for (const write of writes) landed = await write() && landed
} catch {
landed = false
} finally {
// A newer edit made during a write remains staged for the next queued save.
if (landed) {
for (const [field, edit] of staged) {
if (this.staged.get(field) === edit) this.staged.delete(field)
}
}
this.saving = false
this.failed = !landed
this.publish()
}
if (landed) this.staged.clear()
this.saving = false
this.failed = !landed
this.publish()
}
private plan(): PlannedWrite[] {

View file

@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
import type { SnapshotStore } from './snapshot-store.ts'
import {
CardForm, textField, booleanField, booleanFieldPersistFalse,
CardForm, apiUrlField, textField, presetIdsField, booleanFieldPersistFalse,
type CardActions, type CardFieldState, type CardShell,
} from './card-form.ts'
import {
@ -15,6 +15,10 @@ import {
type AlarmPushRpcCall,
type AlarmPushStatus,
} from './alarm-push-status-view.ts'
import {
fetchToolsStatus,
type ToolsMountView,
} from './tools-status-view.ts'
import {
fetchKbStatus,
reloadKbStatus,
@ -35,12 +39,23 @@ import {
} from './sessions-export-view.ts'
import type { SessionsExportReasonCode } from '../session-export-shared.ts'
/** Host Config / configForms namespace (must match cordis entry id). */
export const NETXOPS_NS = 'netxops'
/**
* Settings sidebar slot id — must stay distinct from NETXOPS_NS.
* Colliding with the Config namespace silently drops nav registration on Desktop 0.2.
*/
export const NETXOPS_SECTION_ID = 'dsh-netxops'
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
const API_TOKEN_FIELD = 'apiToken'
const STATUS_POLL_MS = 2_000
export interface NetxopsSettings {
toolsEnabled?: boolean
groupOpsPresets?: string
groupTopologyPresets?: string
groupBizMonitorPresets?: string
groupKbPresets?: string
apiUrl?: string
lang?: string
thinkingLanguage?: string
@ -76,6 +91,13 @@ interface CredentialState {
}
export interface NetxopsCardState extends CardShell {
toolsEnabled: CardFieldState
groupOpsPresets: CardFieldState
groupTopologyPresets: CardFieldState
groupBizMonitorPresets: CardFieldState
groupKbPresets: CardFieldState
presetCatalog: Array<{ id: string; name?: string; broken?: string }>
presetCatalogError: boolean
apiUrl: CardFieldState
lang: CardFieldState
thinkingLanguage: CardFieldState
@ -102,6 +124,8 @@ export interface NetxopsCardState extends CardShell {
apiTokenRemoteReady: boolean
/** Host WSS status when Connection RPC is available; otherwise null. */
alarmPushStatus: AlarmPushStatus | null
/** Host `netx__*` tool mount snapshot; null when RPC unavailable. */
toolsStatus: ToolsMountView | null
/** Latest KB snapshot (saved root, or live preview while editing). */
kbStatus: KbSnapshot | null
/** Soft-injected `remote.directoryPicker` is available. */
@ -136,7 +160,7 @@ export interface NetxopsCardFace extends CardActions {
}
type DirectoryPickerRemote = {
pick: (signal?: AbortSignal) => Promise<string | null>
pick: (signal?: AbortSignal) => Promise<unknown>
}
type CredentialsRemote = {
@ -155,6 +179,11 @@ export class NetxopsCardController {
}
private rpcCall: AlarmPushRpcCall | undefined
private alarmPushStatus: AlarmPushStatus | null = null
private toolsStatus: ToolsMountView | null = null
private toolsStatusInFlight = false
private presetCatalog: NetxopsCardState['presetCatalog'] = []
private presetCatalogError = false
private presetCatalogInFlight = false
private kbStatus: KbSnapshot | null = null
private kbPreview: KbSnapshot | null = null
private kbDirectoryPicker: DirectoryPickerRemote | undefined
@ -181,24 +210,31 @@ export class NetxopsCardController {
this.form = new CardForm(
scope,
[
textField('apiUrl'),
booleanFieldPersistFalse('toolsEnabled'),
presetIdsField('groupOpsPresets'),
presetIdsField('groupTopologyPresets'),
presetIdsField('groupBizMonitorPresets'),
presetIdsField('groupKbPresets'),
apiUrlField('apiUrl'),
textField('lang'),
textField('thinkingLanguage'),
textField('replyLanguage'),
textField('nmsProvider'),
textField('kbRoot'),
// Persist false explicitly: `booleanField` clears on off, which lets a
// profile patch (`groupOpsPublic: true`) win again — uncheck looked
// instant in the UI then tools stayed published.
booleanFieldPersistFalse('groupOpsInPreset'),
// Default is true — must persist false explicitly or "off" snaps back.
booleanFieldPersistFalse('groupOpsPublic'),
booleanField('groupTopologyInPreset'),
booleanField('groupTopologyPublic'),
booleanFieldPersistFalse('groupTopologyInPreset'),
booleanFieldPersistFalse('groupTopologyPublic'),
booleanFieldPersistFalse('groupBizMonitorInPreset'),
booleanField('groupBizMonitorPublic'),
booleanFieldPersistFalse('groupBizMonitorPublic'),
booleanFieldPersistFalse('groupKbInPreset'),
booleanField('groupKbPublic'),
booleanField('alarmPushEnabled'),
booleanFieldPersistFalse('groupKbPublic'),
booleanFieldPersistFalse('alarmPushEnabled'),
booleanFieldPersistFalse('alarmDeliverDsh'),
booleanField('alarmDeliverIm'),
booleanFieldPersistFalse('alarmDeliverIm'),
textField('imTargets'),
textField('imBotId'),
textField('imTargetId'),
@ -239,6 +275,13 @@ export class NetxopsCardController {
this.alarmPushStatus = null
changed = true
}
if (this.toolsStatus !== null) {
this.toolsStatus = null
changed = true
}
this.presetCatalog = []
this.presetCatalogError = true
changed = true
if (this.kbStatus !== null || this.kbPreview !== null) {
this.kbStatus = null
this.kbPreview = null
@ -257,6 +300,8 @@ export class NetxopsCardController {
}
this.startStatusPoll()
void this.refreshAlarmPushStatus()
void this.refreshToolsStatus()
void this.refreshPresetCatalog()
void this.reloadKbFromHost()
void this.refreshKbPreview()
void this.refreshImDeliveryCatalog()
@ -279,6 +324,8 @@ export class NetxopsCardController {
if (this.pollTimer !== undefined) return
this.pollTimer = setInterval(() => {
void this.refreshAlarmPushStatus()
void this.refreshToolsStatus()
void this.refreshPresetCatalog()
void this.refreshKbStatus()
void this.refreshImDeliveryCatalog()
void this.refreshSessionsExportStatus()
@ -316,6 +363,51 @@ export class NetxopsCardController {
}
}
private async refreshToolsStatus(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.toolsStatusInFlight) return
this.toolsStatusInFlight = true
try {
const next = await fetchToolsStatus(call)
const prev = this.toolsStatus
if (
prev
&& prev.count === next.count
&& prev.version === next.version
&& prev.plane === next.plane
&& prev.error === next.error
&& prev.tokenConfigured === next.tokenConfigured
&& prev.apiUrl === next.apiUrl
&& JSON.stringify(prev) === JSON.stringify(next)
) return
this.toolsStatus = next
this.store.set(this.projection())
} catch {
// Keep last good snapshot; next poll retries.
} finally {
this.toolsStatusInFlight = false
}
}
private async refreshPresetCatalog(): Promise<void> {
const call = this.rpcCall
if (!call || this.presetCatalogInFlight) return
this.presetCatalogInFlight = true
try {
const result = await call('/netxops', 'presets.catalog', {}) as {
ok?: boolean; value?: { presets?: NetxopsCardState['presetCatalog'] }
}
if (call !== this.rpcCall) return
this.presetCatalogError = result?.ok !== true || !Array.isArray(result.value?.presets)
if (!this.presetCatalogError) this.presetCatalog = result.value!.presets!
this.store.set(this.projection())
} catch {
if (call !== this.rpcCall) return
this.presetCatalogError = true
this.store.set(this.projection())
} finally { this.presetCatalogInFlight = false }
}
private async refreshImDeliveryCatalog(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.catalogInFlight) return
@ -467,7 +559,7 @@ export class NetxopsCardController {
const trimmed = path.trim()
this.kbUiError = null
this.store.set(this.projection())
await this.form.save()
await this.form.save(['kbRoot'])
const shell = this.form.shell()
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
if (shell.failed || saved !== trimmed) {
@ -595,6 +687,13 @@ export class NetxopsCardController {
private projection(): NetxopsCardState {
return {
toolsEnabled: this.form.field('toolsEnabled'),
groupOpsPresets: this.form.field('groupOpsPresets'),
groupTopologyPresets: this.form.field('groupTopologyPresets'),
groupBizMonitorPresets: this.form.field('groupBizMonitorPresets'),
groupKbPresets: this.form.field('groupKbPresets'),
presetCatalog: this.presetCatalog,
presetCatalogError: this.presetCatalogError,
...this.form.shell(),
apiUrl: this.form.field('apiUrl'),
lang: this.form.field('lang'),
@ -621,6 +720,7 @@ export class NetxopsCardController {
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus,
toolsStatus: this.toolsStatus,
kbStatus: this.kbPreview ?? this.kbStatus,
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
kbUiError: this.kbUiError,
@ -681,6 +781,11 @@ export class NetxopsCardController {
inject(): NetxopsCardFace {
const actions = this.form.actions()
const capabilityFields = new Set([
'toolsEnabled',
'groupOpsPresets',
'groupTopologyPresets',
'groupBizMonitorPresets',
'groupKbPresets',
'groupOpsInPreset',
'groupOpsPublic',
'groupTopologyInPreset',
@ -698,7 +803,7 @@ export class NetxopsCardController {
if (field === 'kbRoot') void this.refreshKbPreview()
// Capability toggles must hit the host Config immediately — staged-only
// edits never publish public tools/skills to other presets.
if (capabilityFields.has(field)) void this.form.save()
if (capabilityFields.has(field)) void this.form.save([field])
},
discard: () => {
actions.discard()
@ -707,7 +812,7 @@ export class NetxopsCardController {
resetField: (field) => {
actions.resetField(field)
if (field === 'kbRoot') void this.refreshKbPreview()
if (capabilityFields.has(field)) void this.form.save()
if (capabilityFields.has(field)) void this.form.save([field])
},
exportAllSessions: () => { this.exportAllSessions() },
browseKbRoot: () => { this.browseKbRoot() },

View file

@ -6,23 +6,32 @@
* inject surfaces as `waiting for service: settingsScope`, the host never
* publishes a connection, and Netx Ops sessions get zero `netx__*` tools.
*
* Surfaces (dual-stack), matching working plugins like dsh-im-ops:
* Desktop 0.2 boot contract (aligned with dsh-search-mcp / dsh-trilium):
* - `dsh.client.immediately: false` — stage-one prefetch must not race Host.
* - Never `ctx.inject(['configForms'])` during apply; poll after apply returns
* (sync inject / accessor wait freezes "Loading plugins…").
* - `settings.section` id ≠ Config namespace (`dsh-netxops` vs `netxops`).
*
* Surfaces (dual-stack):
* - Always register `settings.section` in `apply` (Settings sidebar).
* - Form transport: soft `configForms` (0.2); one-shot `ctx.get('settingsScope')`
* for ≤0.1.5 only — never `ctx.inject(['settingsScope'])`.
* - Form transport: deferred `configForms` poll (0.2); one-shot
* `ctx.get('settingsScope')` for ≤0.1.5 only — never inject settingsScope.
* - Soft-attach real form scopes into a deferred memory scope so Save
* still writes the host entry when the transport is ready.
* - On ≥0.1.7 also register `plugins.item` via `configForms.whileServed`.
* - Also register `settings.plugin.item` via `configForms.whileServed`.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
import { NetxopsCard } from './NetxopsCard.tsx'
import { NETXOPS_NS, NetxopsCardController } from './controller.ts'
import { NETXOPS_NS, NETXOPS_SECTION_ID, NetxopsCardController } from './controller.ts'
import { en, zh, type NetxopsLocaleKey } from './locales.ts'
import type { SettingsFormScope } from './settings-form.ts'
import { createWaitingSettingsScope, findReadySettingsForm } from './settings-form.ts'
import type { NetxopsSettings } from './controller.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@ -55,7 +64,6 @@ function namespaceCandidates(ctx: ClientContext): string[] {
fiber?.options?.id,
fiber?.id,
fiber?.options?.name,
(ctx as { name?: string }).name,
NETXOPS_NS,
'dsh-netxops',
]
@ -88,42 +96,9 @@ type DeferredSettingsScope = SettingsFormScope<NetxopsSettings> & {
attach: (real: SettingsFormScope<NetxopsSettings>) => void
}
/** In-memory form so Settings nav can mount before configForms attaches. */
/** Waiting form so the sidebar can mount without pretending settings were saved. */
function createMemoryScope(initial: NetxopsSettings): SettingsFormScope<NetxopsSettings> {
let value: NetxopsSettings = { ...initial }
let revision = 0
const listeners = new Set<() => void>()
const notify = (): void => {
for (const listener of listeners) listener()
}
return {
getSnapshot: () => ({
status: 'ready',
value,
writable: true,
revision,
}),
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
set: async (field, next) => {
value = { ...value, [field]: next as never }
revision += 1
notify()
return true
},
unset: async (field) => {
const next = { ...value }
delete next[field as keyof NetxopsSettings]
value = next
revision += 1
notify()
return true
},
}
return createWaitingSettingsScope({ ...initial })
}
/** Swap the live transport without recreating the card controller. */
@ -161,6 +136,11 @@ function createDeferredScope(
}
const MEMORY_DEFAULTS: NetxopsSettings = {
toolsEnabled: true,
groupOpsPresets: '',
groupTopologyPresets: '',
groupBizMonitorPresets: '',
groupKbPresets: '',
apiUrl: 'http://127.0.0.1:8890',
lang: 'zh',
thinkingLanguage: 'auto',
@ -174,10 +154,10 @@ const MEMORY_DEFAULTS: NetxopsSettings = {
imTargetId: '',
imTargets: '',
groupOpsInPreset: true,
groupOpsPublic: true,
groupOpsPublic: false,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: false,
groupBizMonitorInPreset: true,
groupBizMonitorPublic: false,
kbRoot: '',
groupKbInPreset: true,
@ -216,11 +196,11 @@ export function apply(ctx: ClientContext): void {
}
}
/** Settings sidebar — same unconditional surface im-ops uses. */
/** Settings sidebar — id must not equal Config ns `netxops`. */
try {
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: NETXOPS_NS,
id: NETXOPS_SECTION_ID,
order: 24,
label: () => t('title'),
locale: LOCALE_NS,
@ -237,11 +217,9 @@ export function apply(ctx: ClientContext): void {
const registerPluginsItem = (id: string): (() => void) => {
if (pluginsItemId === id) return () => {}
try {
const off = ctx.slots.inject('plugins.item', () => ctx.slots.register({
name: 'plugins.item',
id,
order: 24,
label: () => t('title'),
const off = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
key: id,
locale: LOCALE_NS,
inject: () => {
ensureExtras()
@ -263,16 +241,7 @@ export function apply(ctx: ClientContext): void {
forms: ConfigFormsLike,
candidates: readonly string[],
): { ns: string, scope: SettingsFormScope<NetxopsSettings> } | undefined => {
if (typeof forms.get !== 'function') return undefined
for (const ns of candidates) {
try {
const scope = forms.get(ns)
if (scope && typeof scope.getSnapshot === 'function') return { ns, scope }
} catch {
// wrong ns — try next
}
}
return undefined
return findReadySettingsForm(forms, candidates)
}
const attachForm = (
@ -285,67 +254,86 @@ export function apply(ctx: ClientContext): void {
return registerPluginsItem(hit.ns)
}
// DSH ≥0.1.7 / 0.2.0 — configForms (persist + plugins.item).
ctx.inject(['configForms'], (formsCtx) => {
const forms = (formsCtx as { configForms?: ConfigFormsLike }).configForms
if (!forms) {
formsCtx.logger?.warn?.('netxops: configForms inject fired but service missing')
return
}
const candidates = namespaceCandidates(formsCtx)
formsCtx.logger?.info?.('netxops: configForms candidates=%s', candidates.join(','))
if (typeof forms.whileServed === 'function') {
formsCtx.effect(() => forms.whileServed!(candidates, (served) => {
const ns = candidates.find((id) => served.has(id))
if (!ns) {
formsCtx.logger?.warn?.(
'netxops: whileServed fired but none of %s are served (have=%s)',
candidates.join(','),
[...served].join(',') || '(empty)',
)
return () => {}
}
const hit = tryGetForm(forms, [ns, ...candidates])
if (!hit) {
formsCtx.logger?.warn?.('netxops: served %s but configForms.get failed', ns)
return () => {}
}
return attachForm(hit, 'whileServed')
}), 'netxops: configForms whileServed')
}
const immediate = tryGetForm(forms, candidates)
if (immediate) {
formsCtx.effect(
() => attachForm(immediate, 'eager'),
'netxops: configForms eager attach',
// DSH ≥0.1.7 / 0.2.0 — attach configForms by deferred poll.
// Never ctx.inject(['configForms']) or read ctx.configForms during apply:
// on Desktop 0.2 that waits and freezes "Loading plugins…".
let formsAttached = false
const tryAttachForms = (): boolean => {
if (formsAttached) return true
try {
// Direct ctx.configForms requires an inject edge even after apply. get()
// is Cordis' non-blocking lookup for an optional, currently active service.
const forms = ctx.get('configForms') as ConfigFormsLike | undefined
if (!forms || typeof forms.get !== 'function') return false
const candidates = namespaceCandidates(ctx)
const hit = tryGetForm(forms, candidates)
if (!hit) return false
ctx.logger?.info?.('netxops: configForms candidates=%s', candidates.join(','))
ctx.effect(
() => attachForm(hit, 'poll'),
'netxops: configForms poll attach',
)
} else if (typeof forms.whileServed !== 'function') {
formsCtx.logger?.warn?.(
'netxops: configForms present but no get/whileServed match for %s',
candidates.join(','),
)
}
})
// DSH ≤0.1.5 — one-shot settingsScope bind. Do NOT ctx.inject(['settingsScope']):
// on Desktop 0.2 that service is gone and a waiting inject shows up as
// `dsh-netxops: pending (waiting for service: settingsScope)`, which
// prevents the host bridge from publishing a connection → no netx__* tools.
try {
const getter = (ctx as { get?: (name: string) => unknown }).get
if (typeof getter === 'function' && getter.call(ctx, 'configForms') === undefined) {
const binder = getter.call(ctx, 'settingsScope') as SettingsScopeLike | undefined
if (binder && typeof binder.bind === 'function') {
ctx.logger?.info?.('netxops: attach form via settingsScope (legacy one-shot)')
deferred.attach(binder.bind({ namespace: NETXOPS_NS }))
ensureExtras()
if (typeof forms.whileServed === 'function') {
ctx.effect(() => forms.whileServed!(candidates, (served) => {
const ns = candidates.find((id) => served.has(id))
if (!ns) {
ctx.logger?.warn?.(
'netxops: whileServed fired but none of %s are served (have=%s)',
candidates.join(','),
[...served].join(',') || '(empty)',
)
return () => {}
}
const next = tryGetForm(forms, [ns, ...candidates])
if (!next) {
ctx.logger?.warn?.('netxops: served %s but configForms.get failed', ns)
return () => {}
}
return attachForm(next, 'whileServed')
}), 'netxops: configForms whileServed')
}
formsAttached = true
return true
} catch {
return false
}
} catch (error) {
ctx.logger?.warn?.('netxops: legacy settingsScope probe failed: %s', error)
}
/** DSH ≤0.1.5 only — one-shot bind when configForms never appears. */
const tryLegacySettingsScope = (): boolean => {
if (formsAttached) return true
try {
const getter = (ctx as { get?: (name: string) => unknown }).get
if (typeof getter !== 'function') return false
const binder = getter.call(ctx, 'settingsScope') as SettingsScopeLike | undefined
if (!binder || typeof binder.bind !== 'function') return false
ctx.logger?.info?.('netxops: attach form via settingsScope (legacy one-shot)')
deferred.attach(binder.bind({ namespace: NETXOPS_NS }))
ensureExtras()
formsAttached = true
return true
} catch (error) {
ctx.logger?.warn?.('netxops: legacy settingsScope probe failed: %s', error)
return false
}
}
// Defer all form-transport probes until after apply returns.
const startFormsPoll = (): void => {
if (tryAttachForms() || tryLegacySettingsScope()) return
let ticks = 0
const timer = setInterval(() => {
ticks += 1
if (tryAttachForms() || (ticks >= 3 && tryLegacySettingsScope())) {
clearInterval(timer)
}
}, 1_000)
try {
ctx.effect(() => () => clearInterval(timer), 'netxops: configForms poll')
} catch {
clearInterval(timer)
}
}
setTimeout(startFormsPoll, 0)
}
/** Soft-wire credentials / connection / directoryPicker when present. */
@ -378,7 +366,8 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
}, 'remote.credentials')
soft(['connection'], (connCtx) => {
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
const connection = connCtx.get('connection') as { rpc?: { call?: AlarmPushRpcCall } } | undefined
const call = connection?.rpc?.call?.bind(connection.rpc)
if (typeof call !== 'function') {
connCtx.logger?.warn?.('netxops: connection.rpc.call unavailable — alarm status UI disabled')
return
@ -389,21 +378,19 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
}, 'netxops: clear alarm-push rpc')
}, 'connection')
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<string | null> } | undefined): void => {
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<unknown> } | undefined): void => {
if (!picker || typeof picker.pick !== 'function') return
card.setDirectoryPicker(picker as { pick: (signal?: AbortSignal) => Promise<string | null> })
card.setDirectoryPicker({ pick: picker.pick.bind(picker) })
}
bindDirectoryPicker(
(ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
.remote?.directoryPicker,
)
bindDirectoryPicker(ctx.get('remote.directoryPicker') as
{ pick?: (signal?: AbortSignal) => Promise<unknown> } | undefined)
soft(['remote.directoryPicker'], (dpCtx) => {
const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function'
? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as
| { pick?: (signal?: AbortSignal) => Promise<string | null> }
| { pick?: (signal?: AbortSignal) => Promise<unknown> }
| undefined
: undefined
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
const viaNested = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<unknown> } } })
.remote?.directoryPicker
bindDirectoryPicker(viaGet ?? viaNested)
dpCtx.effect(() => () => {

View file

@ -1,6 +1,17 @@
/** Locale keys for the Netx Ops settings section. */
export type NetxopsLocaleKey =
| 'settingsUnavailable'
| 'toolsEnabled'
| 'toolsEnabledHint'
| 'toolsDisabled'
| 'selectedPresets'
| 'publicationHint'
| 'presetCatalogUnavailable'
| 'presetUnavailable'
| 'noOtherPresets'
| 'mountStatusHint'
| 'mountDetails'
| 'title'
| 'description'
| 'sectionConnection'
@ -10,6 +21,7 @@ export type NetxopsLocaleKey =
| 'sectionExport'
| 'apiUrl'
| 'apiUrlHint'
| 'apiUrlInvalid'
| 'lang'
| 'langHint'
| 'thinkingLanguage'
@ -54,6 +66,11 @@ export type NetxopsLocaleKey =
| 'apiTokenUnavailable'
| 'apiTokenSet'
| 'apiTokenUnset'
| 'toolsStatus'
| 'toolsStatusMounted'
| 'toolsStatusEmpty'
| 'toolsStatusError'
| 'toolsStatusUnknown'
| 'sessionsExport'
| 'sessionsExportCount'
| 'sessionsExportUnavailable'
@ -79,6 +96,17 @@ export type NetxopsLocaleKey =
| 'configSaved'
export const en: Record<NetxopsLocaleKey, string> = {
settingsUnavailable: 'Waiting for Host settings. Saving is disabled until connected. If this persists, check that the Host plugin is enabled.',
toolsEnabled: 'Publish Netx capabilities',
toolsEnabledHint: 'Auto-saves. Turn off to withdraw all Netx tools and skills; your group selections are retained.',
toolsDisabled: 'All Netx tools and skills are off',
selectedPresets: 'Selected other presets',
publicationHint: 'Tools and their skills are published together. Changes auto-save and apply to the next model request in current and new sessions. Public capabilities are inherited by every preset, including Netx Ops.',
presetCatalogUnavailable: 'Preset roster unavailable. Existing selections are retained and can be removed.',
presetUnavailable: 'Preset unavailable',
noOtherPresets: 'No other presets available on this Host.',
mountStatusHint: 'Actual mounts on this Host. Selected other presets mount when a session is active. This is registration status; API access is checked during tool calls.',
mountDetails: 'Mount details',
title: 'Netx Ops',
description: 'API, token, capabilities, knowledge base, and alarm delivery.',
sectionConnection: 'Connection',
@ -88,6 +116,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
sectionExport: 'Session export',
apiUrl: 'API URL',
apiUrlHint: 'e.g. http://127.0.0.1:8890',
apiUrlInvalid: 'Must be http(s) without username/password (no file:/javascript:)',
lang: 'API / alarm language',
langHint: 'zh / en — netx API query + alarm prompt copy only (not model reply)',
thinkingLanguage: 'Thinking language',
@ -97,12 +126,12 @@ export const en: Record<NetxopsLocaleKey, string> = {
capabilityGroups: 'Capability groups',
nmsProvider: 'NMS provider',
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupBizMonitor: 'bizMonitor',
groupOps: 'Alarms, inventory and CLI',
groupTopology: 'Topology and layout',
groupBizMonitor: 'Cutover monitoring',
groupKb: 'Knowledge skills',
groupInPreset: 'In Netx Ops preset',
groupPublic: 'Publish to other presets (auto-saves; hot-reloads)',
groupInPreset: 'In Netx Ops preset (auto-saves; mount/unmount immediately)',
groupPublic: 'Publish to every preset (auto-saves)',
kbRoot: 'Package root',
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Browse auto-saves. Pack skills load from paths.skills/_skills and paths.localSkills when hasSkills.',
kbBrowse: 'Browse…',
@ -132,6 +161,11 @@ export const en: Record<NetxopsLocaleKey, string> = {
apiTokenUnavailable: 'remote.credentials unavailable — set NETX_API_TOKEN via script.',
apiTokenSet: 'Configured',
apiTokenUnset: 'Not set',
toolsStatus: 'Agent tools',
toolsStatusMounted: '{count} netx__* tools (v{version})',
toolsStatusEmpty: 'No active Netx tool mounts',
toolsStatusError: 'Tool mount failed: {detail}',
toolsStatusUnknown: 'Host status unavailable',
sessionsExport: 'Export all sessions',
sessionsExportCount: '{count} sessions',
sessionsExportUnavailable: 'Export unavailable',
@ -158,6 +192,17 @@ export const en: Record<NetxopsLocaleKey, string> = {
}
export const zh: Record<NetxopsLocaleKey, string> = {
settingsUnavailable: '正在等待 Host 设置,连接就绪前不能保存。若持续等待,请检查 Host 插件是否启用。',
toolsEnabled: '启用 Netx 能力发布',
toolsEnabledHint: '自动保存。关闭会撤销全部 Netx 工具和 skill,保留各组选择,便于随时恢复。',
toolsDisabled: '全部 Netx 工具和 skill 已关闭',
selectedPresets: '指定其他预设',
publicationHint: '工具与对应 skill 一起发布。选择自动保存,对当前及新会话的下一次模型请求生效。公开能力由所有预设继承,也包括 Netx Ops。',
presetCatalogUnavailable: '暂时无法读取预设列表,已有选择会保留,也可以取消。',
presetUnavailable: '预设不可用',
noOtherPresets: '此 Host 暂无其他预设。',
mountStatusHint: '显示此 Host 的实际挂载。指定其他预设在会话活跃时挂载;此状态表示注册结果,API 权限在调用工具时验证。',
mountDetails: '查看实际挂载明细',
title: 'Netx Ops',
description: 'API、Token、能力组、知识库与告警投递。',
sectionConnection: '连接',
@ -167,6 +212,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
sectionExport: '会话导出',
apiUrl: 'API 地址',
apiUrlHint: '例如 http://127.0.0.1:8890',
apiUrlInvalid: '须为无账号密码的 http(s) 地址(不支持 file:/javascript:)',
lang: 'API / 告警语言',
langHint: 'zh / en — 仅影响 netx API 与告警文案,不控制模型回复',
thinkingLanguage: '思考语言',
@ -176,12 +222,12 @@ export const zh: Record<NetxopsLocaleKey, string> = {
capabilityGroups: '能力组',
nmsProvider: 'NMS 提供方',
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupBizMonitor: 'bizMonitor',
groupOps: '告警、资产与 CLI 运维',
groupTopology: '拓扑与布局',
groupBizMonitor: '割接业务监控',
groupKb: '知识库技能',
groupInPreset: '在 Netx Ops 预设中启用',
groupPublic: '对其他预设公开(自动保存,即时挂载/卸载)',
groupInPreset: '在 Netx Ops 预设中启用(自动保存,即时挂载/卸载)',
groupPublic: '对所有预设公开(自动保存)',
kbRoot: '知识包根目录',
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 paths.skills/_skills 与 paths.localSkills。',
kbBrowse: '浏览…',
@ -211,6 +257,11 @@ export const zh: Record<NetxopsLocaleKey, string> = {
apiTokenUnavailable: '未提供 remote.credentials — 请用脚本写入 NETX_API_TOKEN。',
apiTokenSet: '已配置',
apiTokenUnset: '未设置',
toolsStatus: 'Agent 工具',
toolsStatusMounted: '已挂载 {count} 个 netx__*(v{version})',
toolsStatusEmpty: '当前没有活跃的 Netx 工具挂载',
toolsStatusError: '工具挂载失败:{detail}',
toolsStatusUnknown: '无法读取 Host 状态',
sessionsExport: '导出全部会话',
sessionsExportCount: '{count} 个会话',
sessionsExportUnavailable: '无法导出',

View file

@ -22,3 +22,29 @@ export interface SettingsFormScope<T> {
set(field: string, value: unknown): Promise<boolean | void>
unset(field: string): Promise<boolean | void>
}
/** Read-only placeholder until an actual Host settings namespace is served. */
export function createWaitingSettingsScope<T>(initial: T): SettingsFormScope<T> {
const snapshot: SettingsFormSnapshot<T> = { status: 'loading', value: initial, writable: false }
return {
getSnapshot: () => snapshot,
subscribe: () => () => {},
set: async () => { throw new Error('settings_transport_unavailable') },
unset: async () => { throw new Error('settings_transport_unavailable') },
}
}
/** get() may return a placeholder for any id; only a served namespace is usable. */
export function findReadySettingsForm<T>(
forms: { get?: (id: string) => SettingsFormScope<T> },
namespaces: readonly string[],
): { ns: string; scope: SettingsFormScope<T> } | undefined {
if (!forms.get) return undefined
for (const ns of namespaces) {
try {
const scope = forms.get(ns)
if (scope?.getSnapshot().status === 'ready') return { ns, scope }
} catch { /* Wrong namespace or service not ready; keep trying. */ }
}
return undefined
}

View file

@ -43,6 +43,11 @@ const CSS = `
.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3);padding-top:12px}
.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;padding-left:2px}
.dsh-nx-targets{margin-top:10px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5)}
.dsh-nx-settings summary{cursor:pointer;font-size:12px;font-weight:500;line-height:1.6}
.dsh-nx-targets[open] .dsh-nx-checkRow{padding:5px 0;flex-wrap:wrap}
.dsh-nx-settings input:focus-visible,.dsh-nx-settings button:focus-visible,.dsh-nx-settings summary:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:2px}
.dsh-nx-settings>.dsh-nx-settings-actions{position:sticky;bottom:0;padding:12px;background:var(--dsw-alias-bg-base,#fff);border-top:1px solid var(--dsw-alias-border-l2,#dfe1e5);flex-wrap:wrap}
.dsh-nx-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch}
.dsh-nx-pathRow>input{flex:1;min-width:0}

View file

@ -0,0 +1,108 @@
/**
* Browser-side Host tool-mount status (RPC `tools.status`).
*/
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
/** Same channel as host `NETXOPS_RPC_CHANNEL`. */
export const NETXOPS_RPC_CHANNEL = '/netxops'
export const TOOLS_STATUS_ENDPOINT = 'tools.status'
export interface ToolsMountView {
toolsEnabled: boolean
mounts: Array<{ source: string; count: number; groups: string[]; plane: string; error?: string }>
count: number
version: string
groups: string[]
plane: string
error?: string
tokenConfigured: boolean | null
apiUrl: string
}
const EMPTY: ToolsMountView = {
toolsEnabled: true,
mounts: [],
count: 0,
version: '',
groups: [],
plane: '',
tokenConfigured: null,
apiUrl: '',
}
/** Normalize host `tools.status` RPC value. */
export function asToolsMountView(value: unknown): ToolsMountView {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ...EMPTY }
const row = value as Record<string, unknown>
const mount = row.mount
const connection = row.connection
let count = 0
let version = ''
let groups: string[] = []
let plane = ''
let error: string | undefined
if (mount !== null && typeof mount === 'object' && !Array.isArray(mount)) {
const m = mount as Record<string, unknown>
count = typeof m.count === 'number' ? m.count : 0
version = typeof m.version === 'string' ? m.version : ''
plane = typeof m.plane === 'string' ? m.plane : ''
error = typeof m.error === 'string' ? m.error : undefined
if (Array.isArray(m.groups)) {
groups = m.groups.filter((g): g is string => typeof g === 'string')
}
}
let tokenConfigured: boolean | null = null
let apiUrl = ''
if (connection !== null && typeof connection === 'object' && !Array.isArray(connection)) {
const c = connection as Record<string, unknown>
tokenConfigured = c.tokenConfigured === true
apiUrl = typeof c.apiUrl === 'string' ? c.apiUrl : ''
}
const mounts: ToolsMountView['mounts'] = []
const names = new Set<string>()
if (Array.isArray(row.mounts)) {
for (const raw of row.mounts) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
const m = raw as Record<string, unknown>
const entry = {
source: typeof m.source === 'string' ? m.source : String(m.plane ?? ''),
count: typeof m.count === 'number' ? m.count : 0,
groups: Array.isArray(m.groups) ? m.groups.filter((g): g is string => typeof g === 'string') : [],
plane: typeof m.plane === 'string' ? m.plane : '',
error: typeof m.error === 'string' ? m.error : undefined,
}
mounts.push(entry)
if (Array.isArray(m.names)) for (const name of m.names) if (typeof name === 'string') names.add(name)
if (entry.error) error = entry.error
if (typeof m.version === 'string' && m.version) version = m.version
}
count = names.size
groups = [...new Set(mounts.flatMap(m => m.groups))]
}
const toolsEnabled = !(connection && typeof connection === 'object'
&& (connection as Record<string, unknown>).toolsEnabled === false)
return { count, version, groups, plane, error, tokenConfigured, apiUrl, mounts, toolsEnabled }
}
/** Fetch Host tool mount snapshot. */
export async function fetchToolsStatus(
call: AlarmPushRpcCall,
signal?: AbortSignal,
): Promise<ToolsMountView> {
const result = await call(NETXOPS_RPC_CHANNEL, TOOLS_STATUS_ENDPOINT, {}, signal)
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asToolsMountView((result as { value?: unknown }).value)
}
return { ...EMPTY }
}
/** Badge tone for the tools mount line. */
export function toolsMountTone(view: ToolsMountView | null): 'ok' | 'warn' | 'err' | 'neutral' {
if (!view) return 'neutral'
if (view.error) return 'err'
if (!view.toolsEnabled) return 'neutral'
if (view.count > 0) return 'ok'
return 'warn'
}

View file

@ -19,6 +19,9 @@
* @module dsh-netxops
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
@ -29,7 +32,13 @@ export {
ensureAgentPresetInstalled,
NETXOPS_PRESET_ID,
} from './agent-preset-install.ts'
import { ensureAgentPresetInstalled } from './agent-preset-install.ts'
export {
mergeHostConfigForHeal,
NETXOPS_HOST_CONFIG_DEFAULTS,
writePatchIfUnchanged,
} from './netx/heal-profile-entry.ts'
export { normalizeApiUrl } from './netx/http.ts'
import { ensureAgentPresetInstalled, resolveActiveProfile } from './agent-preset-install.ts'
import { startAlarmPushClient } from './netx/alarm-push.ts'
import {
getAlarmPushStatus,
@ -41,6 +50,7 @@ import { resetAlarmSession } from './netx/alarm-session.ts'
import {
capabilityGroupsFromSettings,
groupsForPlane,
kbEnabledForPlane,
type NetxCapabilityGroupSettingsFields,
} from './netx/capability-groups.ts'
import { registerGroupSkills } from './netx/group-skills.ts'
@ -66,6 +76,7 @@ import {
thinkingInstruction,
thinkingReminder,
} from './netx/model-language.ts'
import { normalizeApiUrl, type NormalizeApiUrlResult } from './netx/http.ts'
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
import {
getSessionsExportStatus,
@ -74,13 +85,33 @@ import {
sessionsExportResponse,
} from './netx/session-export.ts'
import { mountNetxopsWebRoute } from './netx/netxops-web-rpc.ts'
import {
getToolMountStatus,
getToolMountStatuses,
publishToolMountStatus,
resetToolMountStatus,
} from './netx/tool-mount-status.ts'
import { registerNetxTools } from './netx/tools.ts'
import { installPresetPublication } from './netx/preset-publication.ts'
import { readConfigSnapshot, readConfigValue, type ConfigInput } from './netx/config-values.ts'
// The event is absent from older peer declarations, where it is never emitted.
declare module '@deepseek-ai/cordis' {
interface Events {
'loader/volatile-update'(paths: readonly (readonly string[])[]): void
}
}
/** Cordis plugin name. */
export const name = 'netxops'
/** Wait for the credentials store before publishing a Bearer snapshot. */
export const inject = ['credentials']
/**
* Hard-inject `tools` (trilium / official cookbook pattern). Soft `credentials`
* previously left connection unpublished when pending; tools soft-inject could
* also never fire — Host then registered zero `netx__*` globally. Credentials
* stay soft below; we provisional-publish immediately.
*/
export const inject = ['tools']
/** Connection RPC channel for browser status reads. */
export const NETXOPS_RPC_CHANNEL = '/netxops'
@ -91,8 +122,23 @@ export const NETXOPS_SETTINGS_NAMESPACE = 'netxops'
/** Default credential reference for the netx API bearer token. */
export const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
/** Default tool-call timeout when settings omit / null the volatile field. */
export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 120_000
/** Default REST root when settings projection emits null/empty for apiUrl. */
export const DEFAULT_API_URL = 'http://127.0.0.1:8890'
/** Default API lang query when settings projection emits null/empty for lang. */
export const DEFAULT_LANG = 'zh'
/** Plugin / settings section shape. */
export interface Config {
/** Master switch for every Netx tool and skill publication. */
toolsEnabled: boolean
groupOpsPresets: string
groupTopologyPresets: string
groupBizMonitorPresets: string
groupKbPresets: string
/** netx REST root (no trailing slash required). */
apiUrl: string
/** Passed as `lang` query when starting with `en` (API + alarm copy only). */
@ -145,7 +191,7 @@ export interface Config {
nmsProvider: string
/** ops tools/skills in the Netx Ops preset (default on) — NMS + CLI + paths. */
groupOpsInPreset: boolean
/** Publish ops tools/skills to other presets (default on). */
/** Publish ops tools/skills to other presets (default off — Netx Ops only). */
groupOpsPublic: boolean
/** topology canvas / layout tools in the Netx Ops preset (default off). */
groupTopologyInPreset: boolean
@ -177,7 +223,12 @@ function vol<S>(schema: S): S {
return typeof candidate.volatile === 'function' ? candidate.volatile() : schema
}
export const Config: z<Config> = z.object({
export const Config: z<Config, ConfigInput<Config>> = z.object({
toolsEnabled: vol(z.boolean().default(true)),
groupOpsPresets: vol(z.string().default('')),
groupTopologyPresets: vol(z.string().default('')),
groupBizMonitorPresets: vol(z.string().default('')),
groupKbPresets: vol(z.string().default('')),
apiUrl: vol(z.string().default('http://127.0.0.1:8890')),
lang: vol(z.string().default('zh')),
thinkingLanguage: vol(z.string().default('auto')),
@ -193,7 +244,7 @@ export const Config: z<Config> = z.object({
imTargetId: vol(z.string().default('')),
nmsProvider: vol(z.string().default('zte-ume')),
groupOpsInPreset: vol(z.boolean().default(true)),
groupOpsPublic: vol(z.boolean().default(true)),
groupOpsPublic: vol(z.boolean().default(false)),
groupTopologyInPreset: vol(z.boolean().default(false)),
groupTopologyPublic: vol(z.boolean().default(false)),
groupBizMonitorInPreset: vol(z.boolean().default(true)),
@ -203,13 +254,117 @@ export const Config: z<Config> = z.object({
groupKbPublic: vol(z.boolean().default(false)),
})
/** Merge settings projection without letting null/undefined wipe profile defaults. */
export function mergeConfigSkippingNullish(
base: ConfigInput<Config>,
patch: Record<string, unknown>,
): Config {
const live = Object.fromEntries(
Object.entries(readConfigSnapshot(patch)).filter(([, v]) => v !== null && v !== undefined),
)
return { ...readConfigSnapshot(base), ...live }
}
/** Positive finite timeout for `defineTool`; fall back when projection emits null. */
export function resolveToolCallTimeoutMs(
value: unknown,
fallback = DEFAULT_TOOL_CALL_TIMEOUT_MS,
): number {
value = readConfigValue(value)
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
}
type ApiUrlNormalizeError = Extract<NormalizeApiUrlResult, { ok: false }>['error']
/** Heal null/empty lang from settings projection. */
export function resolveLang(value: unknown, fallback = DEFAULT_LANG): string {
value = readConfigValue(value)
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback
}
/**
* Resolve bearer token from the credentials seam (or empty when unset).
* Requires `inject: ['credentials']` so the store is live before apply.
* Resolve a publishable apiUrl.
* null/undefined/"" → default localhost; invalid non-empty → "" (keep warn path).
*/
async function resolveToken(ctx: Context, refName: string): Promise<string> {
const hit = await ctx.credentials.resolve(credentialRef(refName))
return hit?.value ?? ''
export function resolveApiUrlForConnection(value: unknown): {
url: string
usedDefault: boolean
error?: ApiUrlNormalizeError
} {
value = readConfigValue(value)
const raw = typeof value === 'string' ? value.trim() : ''
if (!raw) {
const normalized = normalizeApiUrl(DEFAULT_API_URL)
return {
url: normalized.ok ? normalized.url : DEFAULT_API_URL,
usedDefault: true,
}
}
const normalized = normalizeApiUrl(raw)
if (normalized.ok) return { url: normalized.url, usedDefault: false }
return { url: '', usedDefault: false, error: normalized.error }
}
function packageVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
return typeof pkg.version === 'string' ? pkg.version : '0'
} catch {
return '0'
}
}
/** Heal null/empty/invalid tokenCredentialRef from settings projection. */
export function resolveTokenCredentialRef(value: unknown): string {
value = readConfigValue(value)
return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value.trim())
? value.trim()
: DEFAULT_TOKEN_REF
}
type CredentialsResolve = {
resolve?: (ref: unknown) => Promise<{ value?: string } | undefined>
}
function credentialsFrom(ctx: Context): CredentialsResolve | undefined {
const direct = (ctx as { credentials?: CredentialsResolve }).credentials
if (direct && typeof direct.resolve === 'function') return direct
if (typeof (ctx as { get?: (name: string) => unknown }).get === 'function') {
const viaGet = (ctx as { get: (name: string) => unknown }).get('credentials') as CredentialsResolve | undefined
if (viaGet && typeof viaGet.resolve === 'function') return viaGet
}
return undefined
}
/**
* Resolve bearer token from the credentials seam (or env fallback).
* Never throws — invalid/null ref names fall back to {@link DEFAULT_TOKEN_REF}.
*/
async function resolveToken(
ctx: Context,
refName: unknown,
log?: { warn: (...args: unknown[]) => void },
): Promise<string> {
const ref = resolveTokenCredentialRef(refName)
const fromEnv = (): string => process.env[ref]?.trim() ?? ''
try {
const credentials = credentialsFrom(ctx)
if (!credentials?.resolve) {
const env = fromEnv()
if (!env) log?.warn('netxops: credentials service unavailable; %s empty', ref)
return env
}
const hit = await credentials.resolve(credentialRef(ref))
const value = typeof hit?.value === 'string' ? hit.value.trim() : ''
if (value) return value
const env = fromEnv()
if (!env) log?.warn('netxops: credential %s resolve returned empty', ref)
return env
} catch (error) {
log?.warn('netxops: credential %s resolve failed: %s', ref, error)
return fromEnv()
}
}
/** Normalize settings.describe() across DSH generations (array or { namespaces }). */
@ -236,9 +391,9 @@ function describeRows(describe: unknown): Array<{ ns?: string, value?: unknown }
*/
function installNetxopsSettings(
ctx: Context,
entry: Config,
entry: ConfigInput<Config>,
hooks: {
setSource: (current: () => Config) => void
setSource: (current: () => ConfigInput<Config>) => void
onChange: () => void
},
): void {
@ -246,13 +401,13 @@ function installNetxopsSettings(
installSettingsSection?: (
context: Context,
ns: string,
schema: z<Config>,
schema: z<Config, ConfigInput<Config>>,
base: Config,
sectionHooks: typeof hooks,
) => void
}).installSettingsSection
if (typeof legacy === 'function') {
legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, readConfigSnapshot(entry), hooks)
return
}
@ -261,7 +416,7 @@ function installNetxopsSettings(
installSection?: (
context: Context,
ns: string,
schema: z<Config>,
schema: z<Config, ConfigInput<Config>>,
base: Config,
sectionHooks: typeof hooks,
) => void
@ -269,7 +424,7 @@ function installNetxopsSettings(
}
if (typeof settings.installSection === 'function') {
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks)
settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, readConfigSnapshot(entry), hooks)
return
}
@ -279,9 +434,11 @@ function installNetxopsSettings(
const readLive = (): Config => {
const row = describeRows(settings.describe).find(item => item.ns === NETXOPS_SETTINGS_NAMESPACE)
if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) {
return { ...entry, ...(row.value as Partial<Config>) }
// Settings projection may emit null for volatile scalars; never let null/undefined
// wipe profile defaults (e.g. toolCallTimeoutMs → defineTool throw → zero tools).
return mergeConfigSkippingNullish(entry, row.value as Record<string, unknown>)
}
return entry
return readConfigSnapshot(entry)
}
hooks.setSource(readLive)
hooks.onChange()
@ -317,14 +474,178 @@ function installNetxopsSettings(
/**
* Apply the Netx Ops host bridge (settings + connection publish + optional alarm push).
*/
export function apply(ctx: Context, config: Config = Config({})): void {
let source: () => Config = () => config
let publishing: Promise<void> = Promise.resolve()
export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as Config)): void {
let source: () => Config = () => readConfigSnapshot(config)
const initial = source()
let generation = 0
let disposed = false
let resolvedTokenRef = resolveTokenCredentialRef(initial.tokenCredentialRef)
let stopAlarmPush: (() => void) | undefined
/** Prefer the soft-injected credentials fiber — parent ctx may lack the service. */
let credentialsCtx: Context = ctx
if (config.installAgentPreset) {
ensureAgentPresetInstalled(ctx.logger)
if (initial.installAgentPreset) {
const profile = resolveActiveProfile(ctx.baseUrl)
let scheduled = false
const fallback = setTimeout(() => {
if (!scheduled && !disposed && source().installAgentPreset) {
ensureAgentPresetInstalled(ctx.logger, profile)
}
}, 1000)
ctx.inject(['agentPresets'], presetCtx => {
scheduled = true
clearTimeout(fallback)
let active = true
const timer = setTimeout(() => {
void (async () => {
const registry = presetCtx.get('agentPresets') as {
readDocument?: (id: string) => Promise<{ content: string }>
}
let document: string | undefined
try { document = (await registry.readDocument?.('standard'))?.content }
catch (error) { presetCtx.logger.warn('netxops: cannot read Host standard preset: %s', error) }
if (active && !disposed && source().installAgentPreset) {
ensureAgentPresetInstalled(presetCtx.logger, profile, document)
}
})().catch(error => { presetCtx.logger.warn('netxops: preset installation failed: %s', error) })
}, 0)
presetCtx.effect(() => () => { active = false; clearTimeout(timer) }, 'netxops: Host standard composition')
})
ctx.effect(() => () => { clearTimeout(fallback) }, 'netxops: preset installation fallback')
}
const connectionSnapshot = (current: Config, token: string) => {
const resolved = resolveApiUrlForConnection(current.apiUrl)
if (resolved.error) {
ctx.logger.warn(
'netxops: invalid apiUrl (%s) — using empty url until fixed; use http(s) without embedded credentials',
resolved.error,
)
} else if (resolved.usedDefault) {
ctx.logger.warn(
'netxops: apiUrl missing/null in settings — falling back to %s',
resolved.url,
)
}
return {
toolsEnabled: current.toolsEnabled !== false,
groupKbPresets: current.groupKbPresets ?? '',
apiUrl: resolved.url,
token,
lang: resolveLang(current.lang),
toolCallTimeoutMs: resolveToolCallTimeoutMs(current.toolCallTimeoutMs),
groups: capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields),
groupKbInPreset: typeof current.groupKbInPreset === 'boolean'
? current.groupKbInPreset
: true,
groupKbPublic: current.groupKbPublic === true,
}
}
// Sync provisional publish BEFORE any async work so preset `dsh-netxops/tools`
// can remount in the same tick (credentials resolve may take a while).
const provisional = connectionSnapshot(initial, '')
publishNetxConnection(provisional)
installPresetPublication(ctx)
ctx.logger.info(
'netxops: provisional connection published → %s (token pending credentials)',
provisional.apiUrl || '(invalid apiUrl)',
)
// Host-global tools FIRST (trilium pattern). Hard-inject guarantees tools;
// smoke/unit may call apply without it — resolve via get() so a bare
// `ctx.tools` access cannot throw "without inject". Register before settings
// / credentials so a later soft-inject stall cannot leave the catalog empty.
const toolsApi = (() => {
try {
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('tools')
: undefined
if (fromGet && typeof (fromGet as { register?: unknown }).register === 'function') {
return fromGet as { register: (entry: unknown) => () => void }
}
} catch {
// ignore
}
const direct = (ctx as { tools?: { register: (entry: unknown) => () => void } }).tools
return direct && typeof direct.register === 'function' ? direct : undefined
})()
if (!toolsApi) {
ctx.logger.warn(
'netxops: tools missing on apply — host tool mount skipped (loader should inject tools)',
)
publishToolMountStatus({
version: 'unknown',
plane: 'host',
groups: [],
names: [],
count: 0,
at: new Date().toISOString(),
error: 'tools service missing on host apply',
source: 'host',
})
} else {
let disposeToolsMount: (() => void) | undefined
let disposeKbLocal: (() => void) | undefined
const remountHostTools = (): void => {
if (disposed) return
disposeToolsMount?.()
disposeToolsMount = undefined
const connection = getNetxConnection()
if (!connection) return
const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host')
// trilium-style: register inside ctx.effect so fiber teardown owns the mount
disposeToolsMount = ctx.effect(() => {
try {
const unregister = registerNetxTools(ctx, connection, { plane: 'host' })
ctx.logger.info(
'netxops: host tools groups=[%s] count=%s',
enabled.join(',') || '(none)',
getToolMountStatus()?.count ?? 0,
)
return () => { unregister() }
} catch (error) {
publishToolMountStatus({
version: packageVersion(),
plane: 'host',
groups: [...enabled],
names: [],
count: 0,
at: new Date().toISOString(),
error: String(error),
})
ctx.logger.error('netxops: host tools register failed: %s', error)
return () => {}
}
}, 'netxops: host tools mount')
}
const remountKbLocal = (): void => {
if (disposed) return
disposeKbLocal?.()
disposeKbLocal = undefined
const connection = getNetxConnection()
if (!connection) return
// Host plane = public only (same rule as capability groups).
if (!kbEnabledForPlane(connection, 'host')) return
disposeKbLocal = ctx.effect(() => {
const unregister = registerKbLocalTools(ctx, getKbContext(), { plane: 'host', source: 'netxops-host:kb-local' })
return () => { unregister?.() }
}, 'netxops: host kb-local tools')
}
const stopWatch = watchNetxConnection(() => {
remountHostTools()
remountKbLocal()
})
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
remountHostTools()
remountKbLocal()
ctx.effect(() => () => {
stopWatch()
stopKbWatch()
disposeToolsMount?.()
disposeKbLocal?.()
resetToolMountStatus()
}, 'netxops: dispose host tools')
}
const restartAlarmPush = (current: Config, apiUrl: string, token: string): void => {
@ -346,7 +667,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
return
}
const lang = current.lang
const lang = resolveLang(current.lang)
const deliverDsh = current.alarmDeliverDsh !== false
const imTargets = resolveImTargets({
imTargets: current.imTargets ?? '',
@ -372,30 +693,40 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
const publish = (): void => {
publishing = publishing.then(async () => {
const gen = ++generation
const current = source()
const token = await resolveToken(ctx, current.tokenCredentialRef)
if (gen !== generation) return
if (disposed) return
const gen = ++generation
// Sync publish first (reuse last token) so capability toggles remount in the
// same tick — do not wait on credentials.resolve for group on/off.
const currentSync = source()
const previous = getNetxConnection()
const sameCredential = resolveTokenCredentialRef(currentSync.tokenCredentialRef) === resolvedTokenRef
const sameHost = resolveApiUrlForConnection(currentSync.apiUrl).url === previous?.apiUrl
const prevToken = sameCredential && sameHost ? previous?.token ?? '' : ''
publishNetxConnection(connectionSnapshot(currentSync, prevToken))
const kb = resolveKbRoot(currentSync.kbRoot ?? '')
publishKbContext(kb)
applyKbEnv(kb)
if (!currentSync.alarmPushEnabled) {
stopAlarmPush?.()
stopAlarmPush = undefined
resetAlarmPushStatus()
}
void (async () => {
const current = source()
const tokenRef = resolveTokenCredentialRef(current.tokenCredentialRef)
const token = await resolveToken(credentialsCtx, tokenRef, ctx.logger)
if (disposed || gen !== generation) return
resolvedTokenRef = tokenRef
const apiUrl = current.apiUrl.replace(/\/$/, '')
const tokenConfigured = token.trim().length > 0
const groups = capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields)
if (current.nmsProvider && current.nmsProvider !== 'zte-ume') {
ctx.logger.warn(
'netxops: nmsProvider=%s is not implemented yet; using zte-ume REST adapter',
current.nmsProvider,
)
}
publishNetxConnection({
apiUrl,
token,
lang: current.lang,
toolCallTimeoutMs: current.toolCallTimeoutMs,
groups,
groupKbInPreset: current.groupKbInPreset !== false,
groupKbPublic: current.groupKbPublic === true,
})
const snapshot = connectionSnapshot(current, token)
publishNetxConnection(snapshot)
const kb = resolveKbRoot(current.kbRoot ?? '')
publishKbContext(kb)
applyKbEnv(kb)
@ -410,12 +741,13 @@ export function apply(ctx: Context, config: Config = Config({})): void {
} else if (kb.status === 'error') {
ctx.logger.warn('netxops: knowledge base error — %s', kb.errorMessage)
}
restartAlarmPush(current, apiUrl, token)
restartAlarmPush(current, snapshot.apiUrl, token)
const tokenConfigured = token.trim().length > 0
if (!tokenConfigured) {
ctx.logger.warn(
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
apiUrl,
current.tokenCredentialRef,
snapshot.apiUrl,
tokenRef,
)
} else {
const imSinkCount = resolveImTargets({
@ -424,32 +756,41 @@ export function apply(ctx: Context, config: Config = Config({})): void {
imTargetId: current.imTargetId ?? '',
}).length
ctx.logger.info(
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]',
apiUrl,
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s host=[%s]',
snapshot.apiUrl,
current.alarmPushEnabled === true,
current.alarmDeliverDsh !== false,
imSinkCount,
groupsForPlane(groups, 'public').join(',') || '(none)',
groupsForPlane(snapshot.groups, 'host').join(',') || '(none)',
)
}
}).catch((error) => {
})().catch((error) => {
ctx.logger.error('netxops: connection publish error: %s', error)
})
}
publish()
// Current Loader commits volatile references in place and notifies this
// owning fiber. Read them again and revoke/remount immediately on UI saves.
ctx.on('loader/volatile-update', () => { publish() })
installNetxopsSettings(ctx, config, {
setSource: (current) => {
source = current
source = () => readConfigSnapshot(current())
},
onChange: () => {
publish()
},
})
ctx.on('credentials/reference-updated', (ref) => {
if (String(ref) === source().tokenCredentialRef) publish()
// Soft credentials — never block Host apply on this service.
ctx.inject(['credentials'], (credCtx) => {
credentialsCtx = credCtx
publish()
credCtx.on('credentials/reference-updated', (ref) => {
if (String(ref) === resolveTokenCredentialRef(source().tokenCredentialRef)) publish()
})
})
// Host-global model language: every preset on this Host inherits these
@ -475,7 +816,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})()
: undefined
if (section !== null && typeof section === 'object' && !Array.isArray(section)) {
return { ...source(), ...(section as Partial<Config>) }
return mergeConfigSkippingNullish(source(), section as Record<string, unknown>)
}
return source()
}
@ -525,94 +866,64 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}, 'netxops: model language prompts')
})
// Optional host-layer publish: groups with `public=true` become visible to other presets.
ctx.inject(['tools'], (toolsCtx) => {
let unregisterTools: (() => void) | undefined
let unregisterKbLocal: (() => void) | undefined
const remountPublicTools = (): void => {
unregisterTools?.()
unregisterTools = undefined
const connection = getNetxConnection()
if (!connection) return
const enabled = groupsForPlane(connection.groups, 'public')
try {
unregisterTools = registerNetxTools(toolsCtx, connection, { plane: 'public' })
toolsCtx.logger.info(
'netxops: host public tools groups=[%s]',
enabled.join(',') || '(none)',
)
} catch (error) {
toolsCtx.logger.error('netxops: host public tools register failed: %s', error)
}
}
const remountKbLocal = (): void => {
unregisterKbLocal?.()
unregisterKbLocal = undefined
const connection = getNetxConnection()
if (!connection || connection.groupKbPublic !== true) return
unregisterKbLocal = registerKbLocalTools(toolsCtx, getKbContext())
}
remountPublicTools()
remountKbLocal()
const stopWatch = watchNetxConnection(() => {
remountPublicTools()
remountKbLocal()
})
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
toolsCtx.effect(() => () => {
stopWatch()
stopKbWatch()
unregisterTools?.()
unregisterKbLocal?.()
}, 'netxops: dispose public tools')
})
ctx.inject(['skills'], (skillsCtx) => {
let skillAbort = new AbortController()
let packAbort = new AbortController()
let unregisterSkills: (() => void) | undefined
let unregisterKbSkill: (() => void) | undefined
let unregisterKbPack: (() => void) | undefined
let generation = 0
let packGeneration = 0
const remountPublicSkills = (): void => {
const remountHostSkills = (): void => {
if (disposed) return
const gen = ++generation
skillAbort.abort()
skillAbort = new AbortController()
unregisterSkills?.()
unregisterSkills = undefined
const connection = getNetxConnection()
if (!connection) return
const enabled = groupsForPlane(connection.groups, 'public')
void registerGroupSkills(skillsCtx, enabled, 'netxops-public').then((dispose) => {
const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host')
void registerGroupSkills(skillsCtx, enabled, 'netxops-host', skillAbort.signal).then((dispose) => {
if (gen !== generation) {
dispose()
return
}
unregisterSkills = dispose
skillsCtx.logger.info(
'netxops: host public skills groups=[%s]',
'netxops: host skills groups=[%s]',
enabled.join(',') || '(none)',
)
}).catch((error) => {
skillsCtx.logger.warn('netxops: public skill register failed: %s', error)
skillsCtx.logger.warn('netxops: host skill register failed: %s', error)
})
}
const remountKbSkill = (): void => {
if (disposed) return
unregisterKbSkill?.()
unregisterKbSkill = undefined
const connection = getNetxConnection()
if (!kbEnabledForPlane(connection, 'host')) return
const snapshot = getKbContext()
unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot)
skillsCtx.logger.info(
'netxops: kb-context skill status=%s',
'netxops: kb-context skill status=%s (host/public)',
snapshot.status,
)
}
const remountKbPack = (): void => {
if (disposed) return
const gen = ++packGeneration
packAbort.abort()
packAbort = new AbortController()
unregisterKbPack?.()
unregisterKbPack = undefined
const connection = getNetxConnection()
const enabled = connection?.groupKbPublic === true
const enabled = kbEnabledForPlane(connection, 'host') && Boolean(connection)
void registerKbPackSkills(skillsCtx, getKbContext(), {
enabled,
providerLabel: 'netxops-kb-pack-public',
providerLabel: 'netxops-kb-pack-host',
signal: packAbort.signal,
}).then((dispose) => {
if (gen !== packGeneration) {
dispose()
@ -620,32 +931,64 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
unregisterKbPack = dispose
}).catch((error) => {
skillsCtx.logger.warn('netxops: public kb pack skill register failed: %s', error)
skillsCtx.logger.warn('netxops: host kb pack skill register failed: %s', error)
})
}
remountPublicSkills()
remountKbSkill()
remountKbPack()
const stopWatch = watchNetxConnection(() => {
remountPublicSkills()
remountHostSkills()
remountKbSkill()
remountKbPack()
})
const stopKbWatch = watchKbContext(() => {
remountKbSkill()
remountKbPack()
})
remountHostSkills()
remountKbSkill()
remountKbPack()
skillsCtx.effect(() => () => {
generation += 1
packGeneration += 1
skillAbort.abort()
packAbort.abort()
stopWatch()
stopKbWatch()
unregisterSkills?.()
unregisterKbSkill?.()
unregisterKbPack?.()
}, 'netxops: dispose public skills')
}, 'netxops: dispose host skills')
})
const handleNetxopsRpc = async (endpoint: string, payload?: unknown): Promise<unknown> => {
if (endpoint === 'presets.catalog') {
const registry = ctx.get('agentPresets') as {
list?: () => Promise<Array<{ id: string; name?: string; broken?: string }>>
} | undefined
if (!registry?.list) return { ok: false, error: 'preset_registry_unavailable' }
const presets = await registry.list()
return { ok: true, value: { presets: presets.map(({ id, name, broken }) => ({ id, name, broken })) } }
}
if (endpoint === 'tools.status') {
const mount = getToolMountStatus()
const connection = getNetxConnection()
return {
ok: true,
value: {
mount: mount ?? null,
mounts: getToolMountStatuses(),
connection: connection
? {
apiUrl: connection.apiUrl,
toolsEnabled: connection.toolsEnabled !== false,
tokenConfigured: connection.token.trim().length > 0,
hostGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host'),
presetGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'preset'),
publicGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'public'),
}
: null,
},
}
}
if (endpoint === 'alarm-push.status') {
return { ok: true, value: getAlarmPushStatus() }
}
@ -736,14 +1079,14 @@ export function apply(ctx: Context, config: Config = Config({})): void {
channel: string,
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
) => (() => void) | Promise<void>
const rpc = (mountCtx.connection as { rpc?: { handle?: RpcHandle } } | undefined)?.rpc
const rpc = (mountCtx.get('connection') as { rpc?: { handle?: RpcHandle } } | undefined)?.rpc
if (rpc && typeof rpc.handle === 'function') {
mountCtx.logger.info('netxops: /netxops rpc mounted via connection.rpc.handle (legacy)')
const legacy = rpc.handle(
NETXOPS_RPC_CHANNEL,
(endpoint, payload) => handleNetxopsRpc(endpoint, payload),
)
return () => { void legacy() }
return () => { if (typeof legacy === 'function') legacy() }
}
mountCtx.logger.warn('netxops: /netxops rpc unavailable — settings card status disabled')
return () => {}
@ -752,7 +1095,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
// Browser card: sessions export download via shared /api Fetch route.
ctx.inject(['connection'], (connCtx) => {
const connection = connCtx.connection as {
const connection = connCtx.get('connection') as {
rpc?: { handle?: (
channel: string,
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
@ -770,7 +1113,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
connCtx.logger.warn('netxops: connection.fetch.register unavailable — sessions export download disabled')
} else {
connCtx.effect(() => {
const dispose = fetchApi.register({
const dispose = fetchApi.register!({
path: NETXOPS_SESSIONS_EXPORT_PATH,
methods: ['GET', 'HEAD'],
fetch: async (request) => {
@ -786,7 +1129,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
ctx.effect(() => () => {
disposed = true
generation += 1
publishNetxConnection({ ...provisional, token: '', toolsEnabled: false })
stopAlarmPush?.()
stopAlarmPush = undefined
resetAlarmSession()

View file

@ -18,10 +18,15 @@ export function alarmSubscribeUrl(apiUrl: string): string {
} catch {
return ''
}
// Only promote http(s) → ws(s); never rewrite file:/data:/javascript: etc.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
if (url.username || url.password) return ''
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = '/v1/integrations/dsh-alarm/ws'
url.search = ''
url.hash = ''
url.username = ''
url.password = ''
return url.toString()
}

View file

@ -8,6 +8,8 @@
* - `bizMonitor` → skill `netx-biz-monitor` — cutover / biz_state read + overnight analysis
*/
import { readConfigSnapshot, readConfigValue } from './config-values.ts'
/** Stable group ids used in settings, skill dirs, and registration filters. */
export type NetxCapabilityGroupId = 'ops' | 'topology' | 'bizMonitor'
@ -17,6 +19,8 @@ export interface NetxGroupExposure {
inPreset: boolean
/** Mount on the host tool/skill layer so other presets can see the group. */
public: boolean
/** Additional preset ids selected by the operator. */
presets?: readonly string[]
}
/** Full capability-group policy published with the connection snapshot. */
@ -24,6 +28,10 @@ export type NetxCapabilityGroups = Record<NetxCapabilityGroupId, NetxGroupExposu
/** Flat settings fields (Plugins card + schemastery Config). */
export interface NetxCapabilityGroupSettingsFields {
toolsEnabled?: boolean
groupOpsPresets?: string
groupTopologyPresets?: string
groupBizMonitorPresets?: string
groupOpsInPreset: boolean
groupOpsPublic: boolean
groupTopologyInPreset: boolean
@ -33,10 +41,10 @@ export interface NetxCapabilityGroupSettingsFields {
}
/**
* Default: ops in Ops preset; topology / bizMonitor and all public off.
* Default: ops + bizMonitor in Ops preset; topology and all public off.
*/
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
ops: Object.freeze({ inPreset: true, public: true }),
ops: Object.freeze({ inPreset: true, public: false }),
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }),
})
@ -79,7 +87,6 @@ export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly str
'netx__layoutTopologyView',
'netx__suggestSinkHubs',
'netx__analyzeTopologyViewLayout',
'netx__sinkTopologyDualUnits',
]),
bizMonitor: Object.freeze([
'netx__listBizMonitors',
@ -112,7 +119,7 @@ export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.fre
* Legacy aliases (all map into `ops`):
* - `groupNms*` / `groupCommon*` / `groupManagedNe*`
*
* Legacy `groupTopologyLayout*` ORs into `topology`.
* Legacy `groupTopologyLayout*` applies only when the current flag is absent.
*/
export function capabilityGroupsFromSettings(
fields: Partial<NetxCapabilityGroupSettingsFields & {
@ -126,17 +133,17 @@ export function capabilityGroupsFromSettings(
groupTopologyLayoutPublic?: boolean
}> | null | undefined,
): NetxCapabilityGroups {
const src = fields ?? {}
const src = readConfigSnapshot(fields ?? {})
let opsInPreset = true
if (src.groupOpsInPreset !== undefined) {
opsInPreset = src.groupOpsInPreset !== false
if (typeof src.groupOpsInPreset === 'boolean') {
opsInPreset = src.groupOpsInPreset
} else {
const legacy = [
src.groupNmsInPreset,
src.groupCommonInPreset,
src.groupManagedNeInPreset,
].filter((v): v is boolean => v !== undefined)
].filter((v): v is boolean => typeof v === 'boolean')
if (legacy.length > 0) {
// Any explicit true → on; all explicit false → off.
opsInPreset = legacy.some((v) => v === true)
@ -144,57 +151,88 @@ export function capabilityGroupsFromSettings(
}
let bizMonitorInPreset = true
if (src.groupBizMonitorInPreset !== undefined) {
bizMonitorInPreset = src.groupBizMonitorInPreset !== false
if (typeof src.groupBizMonitorInPreset === 'boolean') {
bizMonitorInPreset = src.groupBizMonitorInPreset
}
return {
ops: {
inPreset: opsInPreset,
public: (() => {
if (src.groupOpsPublic !== undefined) return src.groupOpsPublic !== false
if (typeof src.groupOpsPublic === 'boolean') return src.groupOpsPublic
const legacy = [
src.groupNmsPublic,
src.groupCommonPublic,
src.groupManagedNePublic,
].filter((v): v is boolean => v !== undefined)
// No explicit flag → default on (other presets see ops after install).
if (legacy.length === 0) return true
].filter((v): v is boolean => typeof v === 'boolean')
// No explicit flag → default off (Netx Ops preset only).
if (legacy.length === 0) return false
return legacy.some((v) => v === true)
})(),
...(src.groupOpsPresets ? { presets: parsePresetIds(src.groupOpsPresets) } : {}),
},
topology: {
inPreset: src.groupTopologyInPreset === true
|| src.groupTopologyLayoutInPreset === true,
public: src.groupTopologyPublic === true
|| src.groupTopologyLayoutPublic === true,
inPreset: typeof src.groupTopologyInPreset === 'boolean'
? src.groupTopologyInPreset : src.groupTopologyLayoutInPreset === true,
public: typeof src.groupTopologyPublic === 'boolean'
? src.groupTopologyPublic : src.groupTopologyLayoutPublic === true,
...(src.groupTopologyPresets ? { presets: parsePresetIds(src.groupTopologyPresets) } : {}),
},
bizMonitor: {
inPreset: bizMonitorInPreset,
public: src.groupBizMonitorPublic === true,
...(src.groupBizMonitorPresets ? { presets: parsePresetIds(src.groupBizMonitorPresets) } : {}),
},
}
}
/**
* KB exposure mirrors capability groups: preset plane ↔ inPreset, host/public ↔ public.
* Defaults: inPreset on, public off (same strategy as ops/bizMonitor).
*/
export function kbEnabledForPlane(
flags: { toolsEnabled?: boolean, groupKbInPreset?: boolean, groupKbPublic?: boolean, groupKbPresets?: string } | undefined,
plane: 'preset' | 'public' | 'host',
presetId = 'netxops',
): boolean {
if (flags) flags = readConfigSnapshot(flags)
if (flags?.toolsEnabled === false) return false
if (!flags) return plane === 'preset'
if (plane === 'preset') {
return !flags.groupKbPublic && (presetId === 'netxops'
? flags.groupKbInPreset !== false
: parsePresetIds(flags.groupKbPresets ?? '').includes(presetId))
}
return flags.groupKbPublic === true
}
/**
* Groups enabled for one registration plane.
* - `preset` — `inPreset` (Netx Ops standing mount via `dsh-netxops/tools`)
* - `public` — `public` only
* - `host` — same as `public` (Host-global registry for other presets)
*
* Host must NOT union `inPreset`: otherwise unchecking「公开」leaves tools on the
* global registry whenever「预设内」is still on, so revoke never takes effect.
*/
export function groupsForPlane(
groups: NetxCapabilityGroups | undefined,
plane: 'preset' | 'public',
plane: 'preset' | 'public' | 'host',
only?: readonly NetxCapabilityGroupId[],
): NetxCapabilityGroupId[] {
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS
const enabled = CAPABILITY_GROUP_IDS.filter((id) => (
plane === 'preset' ? policy[id].inPreset : policy[id].public
))
const enabled = CAPABILITY_GROUP_IDS.filter((id) => {
if (plane === 'preset') return policy[id].inPreset
// host === public: only explicitly published groups leave the Ops preset.
return policy[id].public
})
if (!only || only.length === 0) return enabled
const allow = new Set(only)
return enabled.filter((id) => allow.has(id))
}
/**
* Force-enable the listed groups (for `dsh-netxops/tools-<group>` mounts).
* Normalize a group allow-list; publication policy still controls registration.
* Legacy `nms` / `common` aliases resolve to `ops`.
*/
export function groupsForced(
@ -216,3 +254,23 @@ export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]):
}
return out
}
/** Parse the persisted preset-id list. Empty is an explicit empty selection. */
export function parsePresetIds(value: unknown): string[] {
const current = readConfigValue(value)
if (typeof current !== 'string') return []
return [...new Set(current.split(/[\s,,]+/).filter(Boolean))]
}
/** Groups mounted locally in a preset; public groups are inherited from Host. */
export function groupsForPreset(
connection: { toolsEnabled?: boolean, groups?: NetxCapabilityGroups },
presetId: string,
only?: readonly NetxCapabilityGroupId[],
): NetxCapabilityGroupId[] {
if (connection.toolsEnabled === false) return []
const policy = connection.groups ?? DEFAULT_CAPABILITY_GROUPS
return CAPABILITY_GROUP_IDS.filter(id => (!only?.length || only.includes(id))
&& !policy[id].public
&& (presetId === 'netxops' ? policy[id].inPreset : policy[id].presets?.includes(presetId)))
}

28
src/netx/config-values.ts Normal file
View file

@ -0,0 +1,28 @@
/** The current Harness keeps volatile scalars in stable references. Older Hosts
* supply ordinary values. Keep references at the boundary and read a snapshot
* for each operation so Loader updates are visible without a plugin restart.
*/
export interface ConfigReference<T> {
get(): T
}
export type ConfigInput<T extends object> = {
[K in keyof T]: T[K] | ConfigReference<T[K]>
}
// Official cosmokit protocol, shared across its ESM/CJS copies. Do not import
// isVolatile from an external peer: older Harness builds do not export it.
const volatileWrite = Symbol.for('cosmokit.volatile.write')
export function readConfigValue(value: unknown): unknown {
if (value !== null && typeof value === 'object' && volatileWrite in value
&& 'get' in value && typeof value.get === 'function') {
return value.get()
}
return value
}
/** Our Config fields are scalars; this never descends into arbitrary objects. */
export function readConfigSnapshot<T extends object>(input: ConfigInput<T>): T {
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, readConfigValue(value)])) as T
}

View file

@ -199,6 +199,7 @@ export async function registerGroupSkills(
ctx: Context,
groupIds: readonly NetxCapabilityGroupId[],
providerLabel: string,
signal?: AbortSignal,
): Promise<() => void> {
const skillsApi = (ctx as { skills?: { register: (skill: {
name: string
@ -217,31 +218,42 @@ export async function registerGroupSkills(
const disposers: Array<() => void> = []
const enabled = new Set(groupIds)
const log = (ctx as { logger?: { warn: (msg: string, ...args: unknown[]) => void } }).logger
for (const groupId of CAPABILITY_GROUP_IDS) {
if (!enabled.has(groupId)) continue
const { skills, skipped } = await loadGroupSkills(root, groupId)
for (const miss of skipped) {
log?.warn?.('%s: skip skill dir %s (%s)', providerLabel, miss.directory, miss.reason)
try {
for (const groupId of CAPABILITY_GROUP_IDS) {
if (!enabled.has(groupId)) continue
const { skills, skipped } = await loadGroupSkills(root, groupId)
if (signal?.aborted) break
for (const miss of skipped) {
log?.warn?.('%s: skip skill dir %s (%s)', providerLabel, miss.directory, miss.reason)
}
if (skills.length === 0) {
log?.warn?.(
'%s: no skills loaded for group=%s from %s',
providerLabel,
groupId,
join(root, SKILL_DIR_BY_GROUP[groupId]),
)
}
for (const skill of skills) {
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: groupId === 'topology'
? `${skill.content}\n\n## DeepSeek Harness REST capabilities\nUse only tools present in this session. Native layout supports catalog and move_nodes/sink_nodes. Dual-unit/orbit/polish recipes require a separately installed netx-topology MCP. Native analyzeTopologyViewLayout reports basic graph statistics; suggestSinkHubs uses degree ranking. Native sinkTopologyDualUnits is unavailable.\n`
: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider: providerLabel,
source: 'custom',
}))
}
}
if (skills.length === 0) {
log?.warn?.(
'%s: no skills loaded for group=%s from %s',
providerLabel,
groupId,
join(root, SKILL_DIR_BY_GROUP[groupId]),
)
}
for (const skill of skills) {
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider: providerLabel,
source: 'custom',
}))
if (signal?.aborted) {
for (const dispose of disposers.splice(0)) dispose()
}
} catch (error) {
for (const dispose of disposers.splice(0)) dispose()
throw error
}
return () => {

View file

@ -2,11 +2,14 @@
* Shared agent-plane mount: tools + skills for one or more capability groups.
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools'
import {
groupsForced,
groupsForPlane,
groupsForPreset,
kbEnabledForPlane,
type NetxCapabilityGroupId,
} from './capability-groups.ts'
import { registerGroupSkills } from './group-skills.ts'
@ -14,20 +17,36 @@ import { registerKbContextSkill } from './kb-context-skill.ts'
import { registerKbLocalTools } from './kb-local-tools.ts'
import { registerKbPackSkills } from './kb-pack-skills.ts'
import { getKbContext, watchKbContext } from './kb-runtime.ts'
import { tryRecoverMissingHostBridge } from './heal-profile-entry.ts'
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
import {
getNetxConnection,
requireNetxConnection,
watchNetxConnection,
} from './runtime.ts'
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
import { registerNetxTools } from './tools.ts'
function packageVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
return typeof pkg.version === 'string' ? pkg.version : '0'
} catch {
return '0'
}
}
export interface GroupToolsPluginOptions {
/** Cordis plugin name. */
name: string
/**
* `settings` — honor inPreset/public from connection (Ops aggregate mount).
* `forced` — always register `only` groups when connection exists (per-group export).
* `forced` — legacy export label; only filters groups and obeys the publication policy.
*/
mode: 'settings' | 'forced'
/** Group allow-list; omit in settings mode to use all groups. */
only?: readonly NetxCapabilityGroupId[]
/** Explicit target for live session mounts; otherwise resolve the calling preset. */
presetId?: string
}
/**
@ -38,44 +57,50 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
let unregisterKbLocal: (() => void) | undefined
let unregisterSkills: (() => void) | undefined
let skillGeneration = 0
const presetId = (): string => {
if (options.presetId) return options.presetId
const registry = ctx.get('agentPresets') as { composedPreset?: (ctx: Context) => string | undefined } | undefined
return registry?.composedPreset?.(ctx) ?? 'netxops'
}
const mountId = getToolMountId(ctx, options.name)
const resolveGroups = (): NetxCapabilityGroupId[] => {
const connection = getNetxConnection()
if (options.mode === 'forced') {
return groupsForced(options.only ?? [])
}
return groupsForPlane(connection?.groups, 'preset', options.only)
const connection = requireNetxConnection()
return groupsForPreset(connection, presetId(), options.only)
}
const remountTools = (): void => {
unregisterTools?.()
unregisterTools = undefined
const connection = getNetxConnection()
if (connection === undefined) {
// Preset channel can self-heal while the Host bundle row is gone — restore
// `dsh-netxops` into cordis.patch.yml once, then require a full restart.
tryRecoverMissingHostBridge(ctx.logger, options.name)
return
}
const connection = requireNetxConnection()
const enabled = resolveGroups()
if (enabled.length === 0) {
ctx.logger.info('%s: no groups enabled', options.name)
return
}
try {
// Always call register (incl. empty) so revoke publishes count=0 and matches host.
unregisterTools = registerNetxTools(ctx, connection, {
plane: 'preset',
only: options.mode === 'forced' ? enabled : options.only,
forceGroups: options.mode === 'forced' ? enabled : undefined,
forceGroups: enabled,
source: options.name,
})
ctx.logger.info(
'%s: groups=[%s] → %s tokenConfigured=%s',
'%s: groups=[%s] → %s tokenConfigured=%s live=%s',
options.name,
enabled.join(',') || '(none)',
connection.apiUrl,
connection.token.trim().length > 0,
getNetxConnection() !== undefined,
)
} catch (error) {
publishToolMountStatus({
id: mountId,
version: packageVersion(),
plane: 'preset',
groups: [...enabled],
names: [],
count: 0,
at: new Date().toISOString(),
error: String(error),
source: options.name,
})
ctx.logger.error('%s: registerNetxTools failed: %s', options.name, error)
}
}
@ -83,31 +108,48 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
const remountKbLocal = (): void => {
unregisterKbLocal?.()
unregisterKbLocal = undefined
const connection = getNetxConnection()
if (connection === undefined) return
if (connection.groupKbInPreset === false) return
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext())
const connection = requireNetxConnection()
if (options.only || !kbEnabledForPlane(connection, 'preset', presetId())) return
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext(), { plane: 'preset', source: `${options.name}:kb-local` })
}
remountTools()
remountKbLocal()
// Subscribe BEFORE the first remount. Host publish is async (credentials
// resolve); if it lands between remount and watch, tools stay empty forever.
const stopToolWatch = watchNetxConnection(() => {
remountTools()
remountKbLocal()
})
const stopKbToolWatch = watchKbContext(() => { remountKbLocal() })
remountTools()
remountKbLocal()
// Backup poll: covers publish that raced before watch, and slow credentials.
let pollTicks = 0
const pollTimer = setInterval(() => {
pollTicks += 1
if (getNetxConnection() !== undefined) {
remountTools()
remountKbLocal()
clearInterval(pollTimer)
return
}
if (pollTicks >= 40) clearInterval(pollTimer) // ~20s
}, 500)
ctx.inject(['skills'], (skillsCtx) => {
let skillAbort = new AbortController()
let packAbort = new AbortController()
let unregisterKbSkill: (() => void) | undefined
let unregisterKbPack: (() => void) | undefined
let packGeneration = 0
const remountSkills = (): void => {
const gen = ++skillGeneration
skillAbort.abort()
skillAbort = new AbortController()
unregisterSkills?.()
unregisterSkills = undefined
if (getNetxConnection() === undefined) return
const enabled = resolveGroups()
void registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => {
void registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => {
if (gen !== skillGeneration) {
dispose()
return
@ -120,17 +162,22 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
const remountKbSkill = (): void => {
unregisterKbSkill?.()
unregisterKbSkill = undefined
const connection = requireNetxConnection()
if (options.only || !kbEnabledForPlane(connection, 'preset', presetId())) return
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext())
}
const remountKbPack = (): void => {
const gen = ++packGeneration
packAbort.abort()
packAbort = new AbortController()
unregisterKbPack?.()
unregisterKbPack = undefined
const connection = getNetxConnection()
const enabled = connection?.groupKbInPreset !== false
const connection = requireNetxConnection()
const enabled = !options.only && kbEnabledForPlane(connection, 'preset', presetId())
void registerKbPackSkills(skillsCtx, getKbContext(), {
enabled,
providerLabel: `${options.name}-kb-pack`,
signal: packAbort.signal,
}).then((dispose) => {
if (gen !== packGeneration) {
dispose()
@ -142,20 +189,23 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
})
}
remountSkills()
remountKbSkill()
remountKbPack()
const stopSkillWatch = watchNetxConnection(() => {
remountSkills()
remountKbSkill()
remountKbPack()
})
const stopKbWatch = watchKbContext(() => {
remountKbSkill()
remountKbPack()
})
remountSkills()
remountKbSkill()
remountKbPack()
skillsCtx.effect(() => () => {
skillGeneration += 1
packGeneration += 1
skillAbort.abort()
packAbort.abort()
stopSkillWatch()
stopKbWatch()
unregisterSkills?.()
@ -168,11 +218,13 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
})
ctx.effect(() => () => {
clearInterval(pollTimer)
stopToolWatch()
stopKbToolWatch()
unregisterTools?.()
unregisterTools = undefined
unregisterKbLocal?.()
unregisterKbLocal = undefined
removeToolMountStatus(mountId)
}, `${options.name}: dispose tools`)
}

View file

@ -13,7 +13,7 @@
* recovery when only the preset side is still alive).
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
@ -25,6 +25,11 @@ const HOST_HEAL_ONCE = Symbol.for('dsh-netxops.host-bridge-heal-attempted')
/** Defaults merged into missing keys only — never overwrite user values. */
export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | boolean | number>> = Object.freeze({
toolsEnabled: true,
groupOpsPresets: '',
groupTopologyPresets: '',
groupBizMonitorPresets: '',
groupKbPresets: '',
apiUrl: 'http://127.0.0.1:8890',
lang: 'zh',
thinkingLanguage: 'auto',
@ -40,8 +45,8 @@ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | bool
imTargetId: '',
imTargets: '',
groupOpsInPreset: true,
/** Default on: other presets inherit ops tools/skills after install. */
groupOpsPublic: true,
/** Default off: ops stays Netx-Ops-only unless the operator publishes. */
groupOpsPublic: false,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: true,
@ -171,8 +176,22 @@ function needsHeal(entry: HostEntry): boolean {
return false
}
/**
* Fill missing default keys only — never overwrite existing user values,
* and keep unknown user keys for round-trip safety.
*/
export function mergeHostConfigForHeal(
existing: Readonly<Record<string, string | boolean | number>>,
): Record<string, string | boolean | number> {
const out: Record<string, string | boolean | number> = { ...existing }
for (const [key, value] of Object.entries(NETXOPS_HOST_CONFIG_DEFAULTS)) {
if (!Object.hasOwn(out, key)) out[key] = value
}
return out
}
function renderHostEntry(entry: Pick<HostEntry, 'disabled' | 'name' | 'config'>): string[] {
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }
const config = mergeHostConfigForHeal(entry.config)
const out: string[] = [
'- id: netxops',
' name: dsh-netxops',
@ -180,12 +199,35 @@ function renderHostEntry(entry: Pick<HostEntry, 'disabled' | 'name' | 'config'>)
if (entry.disabled === true) out.push(' disabled: true')
else out.push(' disabled: false')
out.push(' config:')
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
out.push(` ${key}: ${formatScalar(config[key]!)}`)
}
for (const [key, value] of Object.entries(config)) {
if (Object.hasOwn(NETXOPS_HOST_CONFIG_DEFAULTS, key)) continue
out.push(` ${key}: ${formatScalar(value)}`)
}
return out
}
/**
* Write only when the on-disk contents still match `expectedRaw`.
* Aborts (returns false) if Plugin Manager / another heal raced us.
*/
export function writePatchIfUnchanged(
patchPath: string,
expectedRaw: string,
nextRaw: string,
): boolean {
if (nextRaw === expectedRaw) return false
const existed = existsSync(patchPath)
const mtimeMs = existed ? statSync(patchPath).mtimeMs : 0
const current = existed ? readFileSync(patchPath, 'utf8') : ''
if (current !== expectedRaw) return false
if (existed && statSync(patchPath).mtimeMs !== mtimeMs) return false
writeFileSync(patchPath, nextRaw, 'utf8')
return true
}
/**
* Insert or expand the Host `dsh-netxops` row in desktop/web `cordis.patch.yml`.
* @returns counts of profiles rewritten (sparse) vs freshly inserted (missing).
@ -232,8 +274,16 @@ export function ensureNetxopsHostInProfilePatches(logger: Context['logger']): Ho
if (nextLines === null || kind === null) continue
const normalized = nextLines.join(nl)
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`
if (withNl === raw) continue
writeFileSync(patchPath, withNl, 'utf8')
if (!writePatchIfUnchanged(patchPath, raw, withNl)) {
if (withNl !== raw) {
logger.warn(
'netxops: skipped heal write for profiles/%s/cordis.patch.yml '
+ '(file changed concurrently — Plugin Manager/HMR race)',
profileName,
)
}
continue
}
if (kind === 'insert') {
inserted += 1
logger.error(

View file

@ -8,10 +8,40 @@ export interface NetxConnection {
timeoutMs: number
/** Read the latest bearer on each request (do not freeze at client creation). */
getToken: () => string
/** Read the latest apiUrl on each request (settings may heal after first mount). */
getApiUrl?: () => string
}
export type NetxJson = Record<string, unknown>
export type NormalizeApiUrlResult =
| { ok: true, url: string }
| { ok: false, error: 'empty' | 'invalid_url' | 'unsupported_scheme' | 'credentials_in_url' }
/**
* Accept only bare http(s) API roots — no credentials-in-URL, no file/data/etc.
* Trailing slash is stripped; path prefixes (e.g. `/netx`) are kept.
*/
export function normalizeApiUrl(raw: string): NormalizeApiUrlResult {
const trimmed = typeof raw === 'string' ? raw.trim() : ''
if (!trimmed) return { ok: false, error: 'empty' }
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return { ok: false, error: 'invalid_url' }
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: 'unsupported_scheme' }
}
if (parsed.username || parsed.password) {
return { ok: false, error: 'credentials_in_url' }
}
const path = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/$/, '')
const search = parsed.search && parsed.search !== '?' ? parsed.search : ''
return { ok: true, url: `${parsed.origin}${path}${search}` }
}
const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
其他: 'Other',
时钟: 'Clock',
@ -19,9 +49,13 @@ const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
电源: 'Power',
}
function asLang(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** Localize a few protocol bucket keys when lang starts with en (match netx-mcp). */
function localizePayload(lang: string, data: NetxJson): NetxJson {
if (!lang.trim().toLowerCase().startsWith('en')) return data
if (!asLang(lang).trim().toLowerCase().startsWith('en')) return data
const proto = data.protocol_summary
if (!Array.isArray(proto)) return data
for (const row of proto) {
@ -44,15 +78,13 @@ function encodeQuery(params: Record<string, string | number | boolean>): string
}
/**
* Build a client bound to apiUrl / lang / timeout; Bearer is resolved per request.
* @param connection - apiUrl / lang / timeout / getToken.
* Build a client bound to apiUrl / lang / timeout; Bearer + apiUrl are resolved per request.
* @param connection - apiUrl / lang / timeout / getToken / optional getApiUrl.
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
*/
export function createNetxClient(connection: NetxConnection) {
const base = connection.apiUrl.replace(/\/$/, '')
const langParams = (): Record<string, string> => {
const lang = connection.lang.trim().toLowerCase()
const lang = asLang(connection.lang).trim().toLowerCase()
if (lang.startsWith('en')) return { lang: 'en' }
return {}
}
@ -67,7 +99,20 @@ export function createNetxClient(connection: NetxConnection) {
signal?: AbortSignal
} = {},
): Promise<NetxJson> {
const token = connection.getToken().trim()
const rawUrl = typeof connection.getApiUrl === 'function'
? connection.getApiUrl()
: connection.apiUrl
const normalized = normalizeApiUrl(typeof rawUrl === 'string' ? rawUrl : '')
const base = normalized.ok ? normalized.url : ''
if (!normalized.ok || !base) {
return {
ok: false,
error: 'netx_api_url_invalid',
detail: `apiUrl must be http(s) without embedded credentials (${normalized.ok ? 'empty' : normalized.error}).`,
}
}
const rawToken = connection.getToken()
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
if (token.length === 0) {
return {
ok: false,

View file

@ -5,6 +5,7 @@
*/
import type { NetxJson } from './http.ts'
export type LosslessJsonValue = string | number | boolean | null | LosslessJsonValue[] | { [key: string]: LosslessJsonValue }
/**
* Detach a plain JSON-compatible value for DSH tool results.
@ -12,15 +13,15 @@ import type { NetxJson } from './http.ts'
* @param value - handler result (may contain undefined holes).
* @returns parsed plain JSON object, or a safe error envelope.
*/
export function toLosslessJson(value: unknown): NetxJson {
export function toLosslessJson(value: unknown): Record<string, LosslessJsonValue> {
try {
const text = JSON.stringify(value)
if (text === undefined) {
return { ok: false, error: 'tool_result_not_json', detail: 'undefined_root' }
}
const parsed: unknown = JSON.parse(text)
const parsed = JSON.parse(text) as LosslessJsonValue
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as NetxJson
return parsed
}
return { ok: true, data: { value: parsed } }
} catch (error) {

View file

@ -18,6 +18,7 @@ import {
import { kbLocalToolsEnabled, resolveKbLocalRoot, type RefArea } from './kb-local-path.ts'
import { getKbContext } from './kb-runtime.ts'
import { toLosslessJson } from './json-safe.ts'
import { getToolMountId, getToolMountStatus, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
@ -33,7 +34,7 @@ function renderJson(_args: unknown, value: unknown) {
}
const jsonOut = {
schema: { type: 'json' as const },
schema: { type: 'object' as const, additionalProperties: true as const },
render: renderJson,
}
@ -72,6 +73,7 @@ function tool(
export function registerKbLocalTools(
ctx: Context,
snapshot: KbSnapshot = getKbContext(),
options: { plane?: 'host' | 'preset'; source?: string } = {},
): () => void {
if (!kbLocalToolsEnabled(snapshot)) return () => {}
@ -220,7 +222,19 @@ export function registerKbLocalTools(
),
]
const disposers = entries.map((entry) => toolsApi.register(entry))
const disposers: Array<() => void> = []
const source = options.source ?? 'netxops-kb-local'
const id = getToolMountId(ctx, source)
const status = { id, source, version: getToolMountStatus()?.version ?? '', plane: options.plane ?? 'preset',
groups: ['kb'], at: new Date().toISOString() }
try {
for (const entry of entries) disposers.push(toolsApi.register(entry))
publishToolMountStatus({ ...status, names: entries.map(entry => entry.name), count: entries.length })
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
publishToolMountStatus({ ...status, names: [], count: 0, error: String(error) })
throw error
}
ctx.logger.info(
'netxops: kb local tools registered count=%s localRoot=%s',
disposers.length,
@ -228,5 +242,6 @@ export function registerKbLocalTools(
)
return () => {
for (const dispose of disposers) dispose()
removeToolMountStatus(id)
}
}

View file

@ -200,7 +200,7 @@ function parseContent(raw: unknown): KbContentFlags {
const mapped = LEGACY_CONTENT_KEY[key]
if (mapped !== undefined) {
// Short keys only fill has* when the contract key is absent (no dual truth).
if (on && !present.has(mapped)) out[mapped] = true
if (on && !present.has(String(mapped))) out[mapped] = true
continue
}
out[key] = on

View file

@ -49,7 +49,7 @@ async function registerSkillsUnderRoot(
skillsApi: { register: SkillsRegister },
root: string,
provider: string,
options: { logMissing: boolean; label: string },
options: { logMissing: boolean; label: string; signal?: AbortSignal },
): Promise<Array<() => void>> {
let entries: string[]
try {
@ -63,40 +63,47 @@ async function registerSkillsUnderRoot(
if (entries.length === 0) return []
const disposers: Array<() => void> = []
for (const entry of entries) {
const full = join(root, entry)
let isDir = false
try {
isDir = (await stat(full)).isDirectory()
} catch {
continue
try {
for (const entry of entries) {
if (options.signal?.aborted) break
const full = join(root, entry)
let isDir = false
try {
isDir = (await stat(full)).isDirectory()
} catch {
continue
}
if (!isDir) continue
let skill
try {
skill = await loadSkillBundle(full)
} catch (error) {
ctx.logger.warn(
'netxops: kb %s skill load failed at %s: %s',
options.label,
full,
error instanceof Error ? error.message : String(error),
)
continue
}
if (!skill) {
ctx.logger.warn('netxops: kb %s skill skipped (invalid SKILL.md): %s', options.label, full)
continue
}
if (options.signal?.aborted) break
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider,
source: 'custom',
}))
}
if (!isDir) continue
let skill
try {
skill = await loadSkillBundle(full)
} catch (error) {
ctx.logger.warn(
'netxops: kb %s skill load failed at %s: %s',
options.label,
full,
error instanceof Error ? error.message : String(error),
)
continue
}
if (!skill) {
ctx.logger.warn('netxops: kb %s skill skipped (invalid SKILL.md): %s', options.label, full)
continue
}
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: skill.content,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider,
source: 'custom',
}))
} catch (error) {
for (const dispose of disposers.splice(0)) dispose()
throw error
}
return disposers
}
@ -113,6 +120,7 @@ export async function registerKbPackSkills(
options: {
enabled: boolean
providerLabel?: string
signal?: AbortSignal
},
): Promise<() => void> {
if (!options.enabled || !kbPackSkillsEnabled(snapshot)) {
@ -128,17 +136,27 @@ export async function registerKbPackSkills(
const disposers: Array<() => void> = []
const skillsRoot = resolveKbSkillsRoot(snapshot)
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
logMissing: true,
label: 'pack',
}))
const localRoot = resolveKbLocalSkillsRoot(snapshot)
if (localRoot) {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
logMissing: false,
label: 'local',
try {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, skillsRoot, provider, {
logMissing: true,
label: 'pack',
signal: options.signal,
}))
if (localRoot) {
disposers.push(...await registerSkillsUnderRoot(ctx, skillsApi, localRoot, provider, {
logMissing: false,
label: 'local',
signal: options.signal,
}))
}
} catch (error) {
for (const dispose of disposers.splice(0)) dispose()
throw error
}
if (options.signal?.aborted) {
for (const dispose of disposers.splice(0)) dispose()
}
if (disposers.length > 0) {

View file

@ -55,12 +55,15 @@ export function getKbContext(): KbSnapshot {
*/
export function publishKbContext(next: KbSnapshot): void {
const state = store()
if (JSON.stringify(state.snapshot) === JSON.stringify(next)) return
state.snapshot = {
...next,
content: { ...next.content },
paths: { ...next.paths },
}
for (const listener of state.listeners) listener()
for (const listener of [...state.listeners]) {
try { listener() } catch (error) { console.error('netxops: knowledge subscriber failed', error) }
}
}
/**

View file

@ -0,0 +1,65 @@
/** Publish selected capabilities into live session scopes without rewriting presets. */
import type { Context } from '@deepseek-ai/cordis'
import { createScope, scopeOf, type Scope } from '@deepseek-ai/dsh-scope'
import { groupsForPreset, kbEnabledForPlane } from './capability-groups.ts'
import { applyGroupToolsPlugin } from './group-tools-plugin.ts'
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
'agent-preset/selected': (sessionId: string, presetId: string) => void
}
}
interface LiveAgent { id: string; ctx: Context }
interface PresetRoster {
composedPreset(ctx: Context): string | undefined
list(): Promise<Array<{ id: string; name?: string; broken?: string }>>
}
/** Attach selected groups to existing and newly created sessions on either Host. */
export function installPresetPublication(ctx: Context): void {
ctx.inject(['agents', 'agentPresets', 'tools'], child => {
const agents = child.get('agents') as { list(): LiveAgent[] }
const presets = child.get('agentPresets') as PresetRoster
const mounts = new Map<string, { presetId: string; scope: Scope }>()
let active = true
const release = (id: string): void => {
const mount = mounts.get(id)
if (!mount) return
mounts.delete(id)
mount.scope.rawDispose()
}
const sync = (agent: LiveAgent): void => {
if (!active) return
const presetId = presets.composedPreset(agent.ctx)
const connection = getNetxConnection()
// Netx Ops owns its standing aggregate mount. Other targets use live scopes.
const enabled = connection && presetId && presetId !== 'netxops'
&& (groupsForPreset(connection, presetId).length > 0
|| kbEnabledForPlane(connection, 'preset', presetId))
const previous = mounts.get(agent.id)
if (!enabled || previous?.presetId !== presetId) release(agent.id)
if (!enabled || mounts.has(agent.id)) return
const key = scopeOf(agent.ctx)
if (!key) {
child.logger.warn('netxops: selected preset %s has no session scope', presetId)
return
}
// Register on the exact live scope; a sibling scope would be invisible to the agent.
const scope = createScope(child, key)
mounts.set(agent.id, { presetId, scope })
applyGroupToolsPlugin(scope.ctx, { name: `netxops-selected:${presetId}:${agent.id}`, mode: 'settings', presetId })
}
const syncAll = (): void => { for (const agent of agents.list()) sync(agent) }
child.on('agent/created', ({ agent }: { agent: LiveAgent }) => { sync(agent) })
child.on('agent/disposed', ({ agent }: { agent: LiveAgent }) => { release(agent.id) })
child.on('agent-preset/selected', () => { syncAll() })
const stop = watchNetxConnection(syncAll)
syncAll()
child.effect(() => () => {
active = false
stop()
for (const id of [...mounts.keys()]) release(id)
}, 'netxops: selected preset publication')
})
}

View file

@ -6,6 +6,8 @@
* even when Bun emits them as separate ESM files.
*/
import { DEFAULT_CAPABILITY_GROUPS } from './capability-groups.ts'
import { normalizeApiUrl } from './http.ts'
import type { NetxToolConnection } from './tools.ts'
type Listener = () => void
@ -33,8 +35,11 @@ function store(): Store {
*/
export function publishNetxConnection(next: NetxToolConnection): void {
const state = store()
if (JSON.stringify(state.connection) === JSON.stringify(next)) return
state.connection = next
for (const listener of state.listeners) listener()
for (const listener of [...state.listeners]) {
try { listener() } catch (error) { console.error('netxops: connection subscriber failed', error) }
}
}
/** @returns the last published connection, if any. */
@ -42,6 +47,33 @@ export function getNetxConnection(): NetxToolConnection | undefined {
return store().connection
}
/**
* Fallback used when the Host bridge has not published yet (credentials still
* pending, or host apply delayed). Publication stays disabled until the Host
* publishes its actual policy, so a detached preset cannot bypass revocation.
*/
export function provisionalNetxConnection(): NetxToolConnection {
const envUrl = process.env.NETX_API_URL?.trim() ?? ''
const normalized = envUrl ? normalizeApiUrl(envUrl) : normalizeApiUrl('http://127.0.0.1:8890')
return {
toolsEnabled: false,
apiUrl: normalized.ok ? normalized.url : 'http://127.0.0.1:8890',
token: process.env.NETX_API_TOKEN?.trim() ?? '',
lang: (typeof process.env.NETX_LANG === 'string' && process.env.NETX_LANG.trim()
? process.env.NETX_LANG.trim()
: 'zh'),
toolCallTimeoutMs: 120_000,
groups: DEFAULT_CAPABILITY_GROUPS,
groupKbInPreset: true,
groupKbPublic: false,
}
}
/** Live snapshot, or a disabled provisional connection until the Host is ready. */
export function requireNetxConnection(): NetxToolConnection {
return getNetxConnection() ?? provisionalNetxConnection()
}
/**
* Subscribe to connection publishes (settings / credential remounts).
* @param listener - called synchronously after each publish.

View file

@ -108,7 +108,7 @@ function resolveSessionStore(ctx: Context): SessionStoreFlushApi | undefined {
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('sessions')
: undefined
const sessions = (fromGet ?? (ctx as { sessions?: SessionStoreFlushApi }).sessions) as
const sessions = (fromGet ?? (ctx as { sessions?: object }).sessions) as
| SessionStoreFlushApi
| undefined
if (!sessions || typeof sessions.get !== 'function' || typeof sessions.flush !== 'function') {

View file

@ -0,0 +1,63 @@
/** Process-wide mount inventory shared by independently bundled plugin entries. */
import { mkdirSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export interface NetxToolMountStatus {
id?: string
version: string
plane: string
groups: string[]
names: string[]
count: number
at: string
error?: string
source?: string
}
interface Store {
mounts: Map<string, NetxToolMountStatus>
contexts: WeakMap<object, number>
sequence: number
}
const KEY = Symbol.for('dsh-netxops.mount-inventory')
function store(): Store {
const root = globalThis as typeof globalThis & { [KEY]?: Store }
return root[KEY] ??= { mounts: new Map(), contexts: new WeakMap(), sequence: 0 }
}
/** Stable registration-owner id, shared across independently built exports. */
export function getToolMountId(ctx: object, source: string): string {
const state = store()
let id = state.contexts.get(ctx)
if (id === undefined) { id = ++state.sequence; state.contexts.set(ctx, id) }
return `${source}:${id}`
}
/** Copies of every mount; remounting one preset never replaces another. */
export function getToolMountStatuses(): NetxToolMountStatus[] {
return [...store().mounts.values()].map(row => ({ ...row, groups: [...row.groups], names: [...row.names] }))
}
/** Legacy singular view always prefers the Host mount. */
export function getToolMountStatus(): NetxToolMountStatus | undefined {
const rows = getToolMountStatuses()
return rows.find(row => row.plane === 'host') ?? rows.at(-1)
}
function persist(): void {
try {
const path = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'netxops-mount-status.json')
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, `${JSON.stringify({ mount: getToolMountStatus() ?? null, mounts: getToolMountStatuses() }, null, 2)}\n`, 'utf8')
} catch {
// Diagnostics are best-effort on read-only installations.
}
}
export function publishToolMountStatus(next: NetxToolMountStatus): void {
store().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] })
persist()
}
export function removeToolMountStatus(id: string): void {
store().mounts.delete(id)
persist()
}
export function resetToolMountStatus(): void {
store().mounts.clear()
persist()
}

View file

@ -2,6 +2,9 @@
* Register native DSH tools (`netx__*`) that call netx REST.
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import {
@ -13,10 +16,23 @@ import {
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import { toLosslessJson } from './json-safe.ts'
import { getNetxConnection } from './runtime.ts'
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
import * as H from './handlers.ts'
import * as T from './topology-handlers.ts'
function packageVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
return typeof pkg.version === 'string' ? pkg.version : '0'
} catch {
return '0'
}
}
export interface NetxToolConnection {
toolsEnabled?: boolean
groupKbPresets?: string
apiUrl: string
token: string
lang: string
@ -33,6 +49,11 @@ export interface NetxToolConnection {
}
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
const MUTATING_TOOLS = new Set([
'netx__execManagedNe', 'netx__createTopologyFolder', 'netx__addTopologyViewNodes',
'netx__removeTopologyViewNodes', 'netx__copyTopologyViewNodes', 'netx__updateTopologyViewPositions',
'netx__projectTopologyNeighbors', 'netx__classifyTopologyFabricNodes', 'netx__layoutTopologyView',
])
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) })
@ -53,8 +74,15 @@ function renderJson(_args: unknown, value: unknown) {
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
}
/**
* Wire-compatible free-form object output.
* Prefer this over author-only `{ type: 'json' }`: Desktop Host ToolRuntime
* often comes from app.asar while `defineTool` resolves from the linked
* checkout — mismatched copies can reject `json` at register time, which
* leaves skills mounted and `netx__*` absent.
*/
const jsonOut = {
schema: { type: 'json' as const },
schema: { type: 'object' as const, additionalProperties: true as const },
render: renderJson,
}
@ -72,7 +100,7 @@ function tool(
parameters: parameters as never,
output: jsonOut,
timeoutMs,
isConcurrencySafe: () => true,
isConcurrencySafe: () => !MUTATING_TOOLS.has(name),
async execute(args, exec) {
const result = await handler(getClient(), args as NetxJson, exec.signal)
// DSH rejects tool results that contain `undefined` property holes
@ -89,13 +117,15 @@ function tool(
export interface RegisterNetxToolsOptions {
/**
* `preset` — groups with `inPreset` (Netx Ops preset scope).
* `public` — groups with `public` (host layer for other presets).
* `public` — groups with `public` only.
* `host` — same as `public` (Host-global registry for other presets).
*/
plane: 'preset' | 'public'
plane: 'preset' | 'public' | 'host'
/** Optional intersect with settings-enabled groups. */
only?: readonly NetxCapabilityGroupId[]
/** When set, register exactly these groups (ignore plane flags). */
forceGroups?: readonly NetxCapabilityGroupId[]
source?: string
}
/**
@ -111,19 +141,43 @@ export function registerNetxTools(
connection: NetxToolConnection,
options: RegisterNetxToolsOptions,
): () => void {
const enabledGroups = options.forceGroups
?? groupsForPlane(connection.groups, options.plane, options.only)
const enabledGroups = connection.toolsEnabled === false ? [] : (options.forceGroups
?? groupsForPlane(connection.groups, options.plane, options.only))
const id = getToolMountId(ctx, options.source ?? options.plane)
const allow = toolNamesForGroups(enabledGroups)
if (allow.size === 0) return () => {}
if (allow.size === 0) {
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
names: [],
count: 0,
at: new Date().toISOString(),
source: options.source ?? options.plane,
})
return () => { removeToolMountStatus(id) }
}
// Guard: DSH defineTool throws if timeoutMs is null/NaN/≤0 (settings projection can emit null).
const t = typeof connection.toolCallTimeoutMs === 'number'
&& Number.isFinite(connection.toolCallTimeoutMs)
&& connection.toolCallTimeoutMs > 0
? connection.toolCallTimeoutMs
: 120_000
const client = createNetxClient({
apiUrl: connection.apiUrl,
lang: connection.lang,
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000),
getToken: () => getNetxConnection()?.token ?? '',
timeoutMs: Math.min(t, 45_000),
getToken: () => {
const live = getNetxConnection()
// A resolved empty token means revoked; never resurrect an ambient token.
return live ? live.token.trim() : connection.token.trim()
},
// Live apiUrl: settings may heal null→default after the first mount.
getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl,
})
const getClient = () => client
const t = connection.toolCallTimeoutMs
const catalog = [
tool(
@ -666,11 +720,39 @@ export function registerNetxTools(
),
]
const disposers = catalog
.filter((entry) => allow.has(entry.name))
.map((entry) => ctx.tools.register(entry))
const selected = catalog.filter((entry) => allow.has(entry.name))
const names = selected.map((entry) => entry.name)
const disposers: Array<() => void> = []
try {
for (const entry of selected) disposers.push(ctx.tools.register(entry))
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
names,
count: names.length,
at: new Date().toISOString(),
source: options.source ?? options.plane,
})
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
names: [],
count: 0,
at: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
source: options.source ?? options.plane,
})
throw error
}
return () => {
for (const dispose of disposers) dispose()
removeToolMountStatus(id)
}
}