Fix Netx Ops tools missing on DSH 0.2 and public-preset publish.

Stop waiting on removed settingsScope so the host can publish a connection, harden skill frontmatter for CRLF, and auto-save capability public toggles so other presets actually get netx__* tools (0.1.50).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-10 16:13:56 +08:00
parent a313dd0eae
commit d4f2459eb2
18 changed files with 567 additions and 178 deletions

View file

@ -679,12 +679,25 @@ export class NetxopsCardController {
inject(): NetxopsCardFace {
const actions = this.form.actions()
const capabilityFields = new Set([
'groupOpsInPreset',
'groupOpsPublic',
'groupTopologyInPreset',
'groupTopologyPublic',
'groupBizMonitorInPreset',
'groupBizMonitorPublic',
'groupKbInPreset',
'groupKbPublic',
])
return {
hooks: { netxopsCard: this.store },
...actions,
edit: (field, text) => {
actions.edit(field, text)
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()
},
discard: () => {
actions.discard()
@ -693,6 +706,7 @@ export class NetxopsCardController {
resetField: (field) => {
actions.resetField(field)
if (field === 'kbRoot') void this.refreshKbPreview()
if (capabilityFields.has(field)) void this.form.save()
},
exportAllSessions: () => { this.exportAllSessions() },
browseKbRoot: () => { this.browseKbRoot() },

View file

@ -1,15 +1,15 @@
/**
* Browser half — Netx Ops settings UI.
*
* Hard-inject only services present on both DSH ≤0.1.5 and ≥0.1.7.
* Do NOT hard-inject `settingsScope`: DSH ≥0.1.7 / 0.2.0 removed it
* (`configForms` replaced it) and a hard wait kills web boot.
* Hard-inject only `slots` + `locale`. Never wait on `settingsScope`
* (removed in DSH ≥0.1.7 / 0.2 — `configForms` replaced it). A pending
* 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:
* - Always register `settings.section` in `apply` (Settings sidebar).
* Gate only the form transport on `configForms` / `settingsScope` —
* never gate the nav entry itself (Desktop 0.2 often never serves
* our ns via whileServed, which previously hid the whole page).
* - Form transport: soft `configForms` (0.2); one-shot `ctx.get('settingsScope')`
* for ≤0.1.5 only — never `ctx.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`.
@ -329,17 +329,23 @@ export function apply(ctx: ClientContext): void {
}
})
// DSH ≤0.1.5 — settingsScope binder.
ctx.inject(['settingsScope'], (scopeCtx) => {
const binder = (scopeCtx as { settingsScope?: SettingsScopeLike }).settingsScope
if (!binder || typeof binder.bind !== 'function') {
scopeCtx.logger?.warn?.('netxops: settingsScope present but .bind missing')
return
// 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()
}
}
scopeCtx.logger?.info?.('netxops: attach form via settingsScope')
deferred.attach(binder.bind({ namespace: NETXOPS_NS }))
ensureExtras()
})
} catch (error) {
ctx.logger?.warn?.('netxops: legacy settingsScope probe failed: %s', error)
}
}
/** Soft-wire credentials / connection / directoryPicker when present. */

View file

@ -102,7 +102,7 @@ export const en: Record<NetxopsLocaleKey, string> = {
groupBizMonitor: 'bizMonitor',
groupKb: 'Knowledge skills',
groupInPreset: 'In Netx Ops preset',
groupPublic: 'Publish to other presets',
groupPublic: 'Publish to other presets (auto-saves; new sessions)',
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…',
@ -181,7 +181,7 @@ export const zh: Record<NetxopsLocaleKey, string> = {
groupBizMonitor: 'bizMonitor',
groupKb: '知识库技能',
groupInPreset: '在 Netx Ops 预设中启用',
groupPublic: '对其他预设公开',
groupPublic: '对其他预设公开(勾选后自动保存;新会话生效)',
kbRoot: '知识包根目录',
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。浏览选目录后会自动保存;hasSkills 时加载 paths.skills/_skills 与 paths.localSkills。',
kbBrowse: '浏览…',

View file

@ -522,11 +522,15 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const connection = getNetxConnection()
if (!connection) return
const enabled = groupsForPlane(connection.groups, 'public')
unregisterTools = registerNetxTools(toolsCtx, connection, { plane: 'public' })
toolsCtx.logger.info(
'netxops: host public tools groups=[%s]',
enabled.join(',') || '(none)',
)
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?.()

View file

@ -56,13 +56,17 @@ export function opsSkillsRoot(): string {
}
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
if (!raw.startsWith('---')) return undefined
const end = raw.indexOf('\n---', 3)
// Windows CRLF (and mixed) checkouts break `$`-anchored YAML lines: a trailing
// `\r` is a JS line terminator, so `.` and `$` both refuse it and description
// silently drops — netx-ops then never registers. Normalize before parse.
const text = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (!text.startsWith('---')) return undefined
const end = text.indexOf('\n---', 3)
if (end < 0) return undefined
const yaml = raw.slice(3, end).replace(/^\r?\n/, '')
const body = raw.slice(end + 4).replace(/^\r?\n/, '')
const yaml = text.slice(3, end).replace(/^\n/, '')
const body = text.slice(end + 4).replace(/^\n/, '')
const data: Record<string, unknown> = {}
const lines = yaml.split(/\r?\n/)
const lines = yaml.split('\n')
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line)
@ -124,18 +128,22 @@ export async function loadSkillBundle(dir: string): Promise<ParsedSkill | null>
}
}
/** Why a skill directory was skipped (for Host logs). */
export type SkillLoadSkip = { directory: string; reason: string }
async function loadGroupSkills(
skillsRoot: string,
groupId: NetxCapabilityGroupId,
): Promise<ParsedSkill[]> {
): Promise<{ skills: ParsedSkill[]; skipped: SkillLoadSkip[] }> {
const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId])
let entries: string[]
try {
entries = await readdir(groupDir)
} catch {
return []
return { skills: [], skipped: [{ directory: groupDir, reason: 'missing group dir' }] }
}
const skills: ParsedSkill[] = []
const skipped: SkillLoadSkip[] = []
for (const entry of entries) {
const full = join(groupDir, entry)
let isDir = false
@ -145,10 +153,39 @@ async function loadGroupSkills(
continue
}
if (!isDir) continue
const skill = await loadSkillBundle(full)
if (skill) skills.push(skill)
const skillPath = join(full, 'SKILL.md')
let raw: string
try {
raw = await readFile(skillPath, 'utf8')
} catch {
skipped.push({ directory: full, reason: 'no SKILL.md' })
continue
}
const parsed = parseFrontmatter(raw)
if (!parsed) {
skipped.push({ directory: full, reason: 'invalid frontmatter' })
continue
}
const name = typeof parsed.data.name === 'string' ? parsed.data.name.trim() : ''
const description = typeof parsed.data.description === 'string'
? parsed.data.description.trim()
: ''
if (!name || !description) {
skipped.push({
directory: full,
reason: !name ? 'missing name' : 'missing description',
})
continue
}
skills.push({
name,
description,
content: parsed.body.trimStart(),
path: skillPath,
directory: full,
})
}
return skills
return { skills, skipped }
}
/**
@ -179,9 +216,21 @@ export async function registerGroupSkills(
const root = opsSkillsRoot()
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 = await loadGroupSkills(root, groupId)
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)
}
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,

View file

@ -59,18 +59,22 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
ctx.logger.info('%s: no groups enabled', options.name)
return
}
unregisterTools = registerNetxTools(ctx, connection, {
plane: 'preset',
only: options.mode === 'forced' ? enabled : options.only,
forceGroups: options.mode === 'forced' ? enabled : undefined,
})
ctx.logger.info(
'%s: groups=[%s] → %s tokenConfigured=%s',
options.name,
enabled.join(',') || '(none)',
connection.apiUrl,
connection.token.trim().length > 0,
)
try {
unregisterTools = registerNetxTools(ctx, connection, {
plane: 'preset',
only: options.mode === 'forced' ? enabled : options.only,
forceGroups: options.mode === 'forced' ? enabled : undefined,
})
ctx.logger.info(
'%s: groups=[%s] → %s tokenConfigured=%s',
options.name,
enabled.join(',') || '(none)',
connection.apiUrl,
connection.token.trim().length > 0,
)
} catch (error) {
ctx.logger.error('%s: registerNetxTools failed: %s', options.name, error)
}
}
const remountKbLocal = (): void => {