feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews
DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).
Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
addressed by the plugin row's Loader entry id and read from the
module's exported Config. The preferences are merged into Config and
marked volatile — which requires DSH's own schemastery, because only
that build wraps a volatile field in the cosmokit reference the
loader's volatile commit walks. With the public package a settings
write reports success and revision 1 while the effective value never
changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
mode is render-time, not load-time. Five call sites that relied on the
old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
boundary injection now writes plugin:dsh-better-sidebar, the same kind
DSH's own v3->v4 migration produces, so historical threads survive.
Tool results moved to role 'tool' with top-level content/isError; all
four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
and the on-demand subagent-catalog observation API. The Tasks page
reads projectionsBySession and a new jobs.list route.
Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
spreadsheet worker, zoom, per-directory refresh); editor.canOpen
refuses those 26 extensions. Markdown, HTML (with its sandbox
switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
host, fixing a regression from the 0.1.6 round: the fallback pointed
at a 'browser' type the plugin no longer registers, so http links in
its own markdown did nothing.
Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
refreshSubagents do not exist on the current baseline, so the
topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
primitives call at module-evaluation time; and the e2e token exchange
needs Playwright's request stack, not Node's fetch.
This commit is contained in:
+298
-48
@@ -17,10 +17,10 @@ import { basename, dirname, extname, isAbsolute, join } from 'node:path'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
import type { Context, SidebarHttpRequest, SidebarSessionEvent } from './context-types.ts'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import type { Context, SidebarHttpRequest, SidebarSessionEvent, SidebarSettingsService } from './context-types.ts'
|
||||
import {
|
||||
Config,
|
||||
PrefsSchema,
|
||||
resolveSidebarConfig,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
@@ -36,6 +36,7 @@ import { searchFiles } from './fs-search.ts'
|
||||
import { decodeHtmlUrl } from './html-route.ts'
|
||||
import { isTrustedApiRequest } from './trust-fence.ts'
|
||||
import { registerBundleRoute } from './bundle-route.ts'
|
||||
import { createDirectoryWatchers, type DirectoryWatchers } from './fs-watch.ts'
|
||||
import { launchExternal } from './open-external.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
|
||||
@@ -470,12 +471,14 @@ function buildApi(
|
||||
const window = filtered.length > CHANGES_EVENTS_CAP ? filtered.slice(filtered.length - CHANGES_EVENTS_CAP) : filtered
|
||||
return { events: window, lastSeq: window.at(-1)?.seq ?? afterSeq }
|
||||
},
|
||||
// Background jobs: read one job's output (a REPLAY of what the model
|
||||
// has read so far, from the owner session's event log — the model's
|
||||
// job_output cursor is never touched, so the human pane can never steal
|
||||
// the agent's bytes), and kill one job. The job LIST itself arrives
|
||||
// through the harness's session/jobs push mirror, so no list route
|
||||
// exists. Kill is fenced to the owning session by the jobs registry.
|
||||
// Background jobs: list the caller's own jobs, read one job's output (a
|
||||
// REPLAY of what the model has read so far, from the owner session's
|
||||
// event log — the model's job_output cursor is never touched, so the
|
||||
// human pane can never steal the agent's bytes), and kill one job. The
|
||||
// list route exists because DSH 0.1.7 deleted the session/jobs push
|
||||
// mirror the Tasks page used to read. Kill is fenced to the owning
|
||||
// session by the jobs registry.
|
||||
'jobs.list': (payload) => jobsApi.list(payload),
|
||||
'jobs.output': (payload) => jobsApi.output(payload),
|
||||
'jobs.kill': (payload) => jobsApi.kill(payload),
|
||||
// Subagent live previews: one batch request per refresh; the route folds
|
||||
@@ -536,6 +539,123 @@ function buildApi(
|
||||
}
|
||||
}
|
||||
|
||||
/** The npm package name of this plugin, exactly as its Loader row declares it. */
|
||||
const SIDEBAR_PACKAGE_NAME = 'dsh-better-sidebar'
|
||||
|
||||
/**
|
||||
* Profile entry id of the sibling `dsh-web-ui` right panel this sidebar yields
|
||||
* to when it is the active provider. Kept as a literal: it is that plugin's
|
||||
* own mount choice, not a contract this plugin can derive.
|
||||
*/
|
||||
const AIONUI_PANEL_ENTRY = 'aionui-panel'
|
||||
|
||||
/** The file-backed settings document DSH 0.1.7 retired. */
|
||||
const LEGACY_SETTINGS_FILE = 'settings.yaml'
|
||||
|
||||
/**
|
||||
* The Loader entry id of this plugin's own row.
|
||||
*
|
||||
* DSH 0.1.7 addresses settings forms by profile entry id, and that id is a
|
||||
* mount choice rather than a package property: this bundle's patch uses
|
||||
* `better-sidebar`, while an aggregate bundle mounts the same package under
|
||||
* its own id. The row is therefore identified by the package name plus fiber
|
||||
* identity, with an enabled same-name row as the fallback for the moment
|
||||
* before the fiber is attached.
|
||||
* @param ctx - the plugin's own context.
|
||||
* @returns the row's configured id, or undefined when no row can be identified.
|
||||
*/
|
||||
function ownEntryId(ctx: Context): string | undefined {
|
||||
let fallback: string | undefined
|
||||
try {
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const id = entry.options.id
|
||||
if (entry.options.name !== SIDEBAR_PACKAGE_NAME || typeof id !== 'string' || id === '') continue
|
||||
if (entry.fiber === ctx.fiber) return id
|
||||
if (entry.disabled !== true && fallback === undefined) fallback = id
|
||||
}
|
||||
} catch {
|
||||
// A loader that does not expose its entries leaves the settings face
|
||||
// absent; the client keeps the schema defaults, which is also what a
|
||||
// deployment without the settings service does.
|
||||
return undefined
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this plugin's preference section out of the retired `settings.yaml`.
|
||||
*
|
||||
* Both names are tried: the settings service renames the document before it
|
||||
* imports any section, so on a host that already booted once only the
|
||||
* `.imported` copy is left, while a host migrated for the first time may still
|
||||
* be mid-import.
|
||||
* @param home - the harness home the retired document lives under.
|
||||
* @returns the section's own fields, or undefined when no usable section exists.
|
||||
*/
|
||||
async function readLegacyPrefs(home: string): Promise<Record<string, unknown> | undefined> {
|
||||
const declared = new Set(Object.keys(Config.dict ?? {}))
|
||||
for (const name of [`${LEGACY_SETTINGS_FILE}.imported`, LEGACY_SETTINGS_FILE]) {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(join(home, name), 'utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
let document: unknown
|
||||
try {
|
||||
document = parseYaml(text)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (document === null || typeof document !== 'object' || Array.isArray(document)) continue
|
||||
const section = (document as Record<string, unknown>)[SIDEBAR_PREFS_NS]
|
||||
if (section === null || typeof section !== 'object' || Array.isArray(section)) continue
|
||||
// Drop fields the current row schema no longer declares (the retired
|
||||
// terminal and browser keys, `defaultWidthPercent`, …). A form write
|
||||
// validates every key against the schema, so one unknown field would
|
||||
// reject the whole patch and lose exactly what this import exists to save.
|
||||
const filtered = Object.fromEntries(
|
||||
Object.entries(section as Record<string, unknown>).filter(([key]) => declared.has(key)),
|
||||
)
|
||||
if (Object.keys(filtered).length > 0) return filtered
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time import of the Side card preferences a pre-0.1.7 release persisted.
|
||||
*
|
||||
* The 0.1.6 line stored them through the file-backed settings provider, in
|
||||
* `$DSH_HOME/settings.yaml` under a `dsh-better-sidebar` section. This release
|
||||
* deletes that provider; its migration renames the document to
|
||||
* `settings.yaml.imported` and re-imports each section into the entry of the
|
||||
* SAME id — and because a section key is the package name while the row id is
|
||||
* a mount choice, DSH warns and leaves this section behind. Without this
|
||||
* import every existing user would silently lose their preferences.
|
||||
*
|
||||
* The import runs only while the row's user layer is still empty, so it can
|
||||
* never overwrite a value set after the upgrade, and re-running it is a no-op.
|
||||
* @param ctx - host plugin context (profile home, logger).
|
||||
* @param settings - the settings forms service.
|
||||
* @param ns - this plugin row's entry id.
|
||||
*/
|
||||
async function importLegacyPrefs(
|
||||
ctx: Context,
|
||||
settings: SidebarSettingsService,
|
||||
ns: string,
|
||||
): Promise<void> {
|
||||
const home = ctx.profileContext?.home
|
||||
if (home === undefined) return
|
||||
const row = settings.describe().find(candidate => candidate.ns === ns)
|
||||
if (row === undefined) return
|
||||
const user = row.user
|
||||
if (user !== null && typeof user === 'object' && Object.keys(user).length > 0) return
|
||||
const section = await readLegacyPrefs(home)
|
||||
if (section === undefined) return
|
||||
await settings.update(ns, section)
|
||||
ctx.logger.info('dsh-better-sidebar: imported Side card preferences from %s', LEGACY_SETTINGS_FILE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin body: mount the fenced routes and the sidebar_open push socket.
|
||||
* @param ctx - host plugin context (webServer, sessions, webRuntime).
|
||||
@@ -555,30 +675,36 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// `/sidebar/ws/agent-opens` socket.
|
||||
const agentOpenRegistry = new AgentOpenRegistry()
|
||||
|
||||
// Register the namespace with the settings provider so the Settings page
|
||||
// (client half) can render and persist the new-conversation defaults. The
|
||||
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
|
||||
// configuration clients, so the client reaches this namespace through the
|
||||
// plugin's own fenced routes below ('settings.get'/'settings.update'),
|
||||
// which call the seam in-process. Deployments without a settings service
|
||||
// simply never fill the face and the client falls back to the defaults.
|
||||
// DSH 0.1.7 replaced the registrable settings namespace with a forms
|
||||
// service over the profile's own entries: a form is addressed by the plugin
|
||||
// ROW's Loader entry id, its schema is this module's exported `Config`, and
|
||||
// its value is the live fiber config. The client still reaches the
|
||||
// preferences through the plugin's own fenced routes below
|
||||
// ('settings.get'/'settings.update'), which now call `describe`/`update`.
|
||||
// Deployments without a settings service simply never fill the face and the
|
||||
// client falls back to the schema defaults.
|
||||
let settingsFace: SidebarSettingsFace | undefined
|
||||
// The model-facing `sidebar_open` tool is gated on the side-card setting
|
||||
// `agentOpenTools` (default off): nothing is injected until the user turns
|
||||
// the feature on; turning it off mid-session unregisters the tool.
|
||||
let openToolsDisposers: (() => void) | null = null
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
// DSH 0.1.2-alpha.2 validates namespaces at compile time
|
||||
// (SettingsNamespaceInput); the 'dsh-better-sidebar' literal passes, so the
|
||||
// runtime helper this used to call (settingsNamespace) is gone upstream.
|
||||
const ns = SIDEBAR_PREFS_NS
|
||||
// The structural settings mirror types `schema` as unknown, so the
|
||||
// generic is not inferred here; the real service resolves it from the
|
||||
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
|
||||
const scope = sctx.settings.register(ns, PrefsSchema) as {
|
||||
get(): SidebarPrefs
|
||||
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
|
||||
// The form is the plugin ROW, so the id is whatever mounted this package:
|
||||
// this bundle's patch uses `better-sidebar`, an aggregate bundle mounts
|
||||
// the same package under its own id. A row the loader cannot identify has
|
||||
// no form, so the face stays absent and the client keeps the defaults.
|
||||
const ns = ownEntryId(ctx)
|
||||
if (ns === undefined) {
|
||||
ctx.logger?.warn?.('dsh-better-sidebar: no loader row for this package; Side card preferences stay at defaults')
|
||||
return
|
||||
}
|
||||
// The plugin ships its own Side card settings section, so the native
|
||||
// auto-form is opted out — otherwise Settings would render the same ~30
|
||||
// preference fields twice. The policy does not affect reads or writes.
|
||||
ctx.effect(
|
||||
() => sctx.settings.configure({ auto: false }, ctx.fiber),
|
||||
'dsh-better-sidebar: settings page policy',
|
||||
)
|
||||
const viewOf = (): { value?: unknown; revision?: number } => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
||||
return descriptor === undefined
|
||||
@@ -587,42 +713,33 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
}
|
||||
// Mutual exclusion with the dsh-web-ui family right panel: the aionui
|
||||
// panel's provider choice (`aionui-panel.rightPanel`) is the authority.
|
||||
// While it resolves to 'aionui-panel', this sidebar must not mount. The
|
||||
// namespace is read through the settings seam like any other registered
|
||||
// section; absent namespace (no aionui installed) = not disabled.
|
||||
// While it resolves to 'aionui-panel', this sidebar must not mount. A form
|
||||
// is addressed by its owner's profile entry id, which for that plugin is
|
||||
// the string its pre-0.1.7 section already used; absent row (no aionui
|
||||
// installed) = not disabled.
|
||||
const externalDisable = (): boolean => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true })
|
||||
.find(candidate => candidate.ns === 'aionui-panel')
|
||||
.find(candidate => candidate.ns === AIONUI_PANEL_ENTRY)
|
||||
const value = descriptor?.value as { rightPanel?: unknown } | undefined
|
||||
return value?.rightPanel === 'aionui-panel'
|
||||
}
|
||||
settingsFace = {
|
||||
get: viewOf,
|
||||
externalDisable,
|
||||
update: async (patch, expectedRevision) => {
|
||||
await sctx.settings.update(ns, patch, expectedRevision)
|
||||
return viewOf()
|
||||
},
|
||||
}
|
||||
// The model-facing open tool is gated on `agentOpenTools`
|
||||
// (default off): nothing is injected until the user turns the feature
|
||||
// on, and turning it off mid-session unregisters the tool and drops the
|
||||
// queued (undelivered) open requests. Already-delivered opens keep their
|
||||
// tabs — the tools' only lever is the queue, not the rendered state.
|
||||
const prefsOf = (): SidebarPrefs => {
|
||||
const value = viewOf().value
|
||||
return value !== null && typeof value === 'object' ? value as SidebarPrefs : SIDEBAR_PREFS_DEFAULTS
|
||||
}
|
||||
const syncOpenToolsGate = (): void => {
|
||||
if (scope.get().agentOpenTools) {
|
||||
if (prefsOf().agentOpenTools === true) {
|
||||
if (openToolsDisposers === null) {
|
||||
openToolsDisposers = registerOpenTool(
|
||||
ctx,
|
||||
agentOpenRegistry,
|
||||
(sessionId) => sessionCwdOf(ctx, sessionId),
|
||||
() => {
|
||||
const view = settingsFace?.get()
|
||||
const value = view?.value
|
||||
return value !== null && typeof value === 'object'
|
||||
? value as SidebarPrefs
|
||||
: SIDEBAR_PREFS_DEFAULTS
|
||||
},
|
||||
prefsOf,
|
||||
)
|
||||
}
|
||||
} else if (openToolsDisposers !== null) {
|
||||
@@ -631,10 +748,26 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
agentOpenRegistry.drainAll()
|
||||
}
|
||||
}
|
||||
settingsFace = {
|
||||
// Two triggers cover what the 0.1.6 namespace watch used to: the
|
||||
// plugin's own writes flow through `update`, and the client re-reads
|
||||
// this face on every `settings/document-updated` push. (The host emits
|
||||
// that event on the settings service's own context, which is not an
|
||||
// ancestor of this plugin's fiber, so a listener here would never run.)
|
||||
get: () => { syncOpenToolsGate(); return viewOf() },
|
||||
externalDisable,
|
||||
update: async (patch, expectedRevision) => {
|
||||
await sctx.settings.update(ns, patch, expectedRevision)
|
||||
return viewOf()
|
||||
},
|
||||
}
|
||||
syncOpenToolsGate()
|
||||
// Settings commits re-evaluate the open tool's gate (the gate is
|
||||
// idempotent and owns its own disposer).
|
||||
scope.watch(() => { syncOpenToolsGate() })
|
||||
// A pre-0.1.7 release persisted these preferences through the file-backed
|
||||
// settings provider, which this release deleted. Import that section once.
|
||||
void importLegacyPrefs(ctx, sctx.settings, ns).catch((error: unknown) => {
|
||||
ctx.logger?.warn?.('dsh-better-sidebar: legacy preference import skipped')
|
||||
ctx.logger?.warn?.(error)
|
||||
})
|
||||
})
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
@@ -849,13 +982,130 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
},
|
||||
}), 'dsh-better-sidebar: agent-opens push WebSocket')
|
||||
|
||||
// ── File-tree directory watch WebSocket ────────────────────────────────
|
||||
// The file tree lists a folder when it is expanded and would otherwise stay
|
||||
// stale for the rest of the session. One socket per session carries the
|
||||
// reader's expanded-folder set; the host watches exactly those directories
|
||||
// and pushes a debounced notice per change, so the tree re-lists in place.
|
||||
// Paths are resolved through the same workspace fence as `fs.tree`, so a
|
||||
// watch can never observe a directory the tree itself could not list.
|
||||
const fsWatchWss = new WebSocketServer({ noServer: true })
|
||||
ctx.effect(() => ctx.webServer.registerUpgrade({
|
||||
path: '/sidebar/ws/fs-watch',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
fsWatchWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
|
||||
void attachFsWatch(ctx, ws, req, () => fenceEnabledOf(() => settingsFace))
|
||||
})
|
||||
},
|
||||
}), 'dsh-better-sidebar: file-tree watch WebSocket')
|
||||
|
||||
ctx.effect(() => () => {
|
||||
openToolsDisposers?.()
|
||||
agentOpenRegistry.dispose()
|
||||
agentOpenWss.close()
|
||||
fsWatchWss.close()
|
||||
}, 'dsh-better-sidebar: teardown')
|
||||
}
|
||||
|
||||
/** One `watch` / `unwatch` frame from the file tree. */
|
||||
interface FsWatchFrame {
|
||||
op?: unknown
|
||||
path?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one session's directory-watch socket until it closes.
|
||||
*
|
||||
* Frames are `{ op: 'watch' | 'unwatch', path }`, where `path` is relative to
|
||||
* the session's workspace exactly like `fs.tree`'s. A path that fails
|
||||
* resolution, or a rejection past the watcher cap, is answered with
|
||||
* `{ dir, ok: false }` so the client can stop asking rather than retry.
|
||||
* @param ctx - host plugin context (session cwd, workspace fence).
|
||||
* @param ws - the accepted socket.
|
||||
* @param req - the upgrade request carrying `?sessionId=`.
|
||||
* @param fenceEnabled - whether the workspace containment fence is on.
|
||||
*/
|
||||
async function attachFsWatch(
|
||||
ctx: Context,
|
||||
ws: WebSocket,
|
||||
req: SidebarHttpRequest,
|
||||
fenceEnabled: () => boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
if (sessionId === null) {
|
||||
ws.close(1008, 'sessionId is required')
|
||||
return
|
||||
}
|
||||
const watchers = createDirectoryWatchers(
|
||||
(event) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ dir: event.dir }))
|
||||
},
|
||||
(dir, error) => {
|
||||
ctx.logger.warn('dsh-better-sidebar: cannot watch %s', dir)
|
||||
ctx.logger.warn(error)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ dir, ok: false }))
|
||||
},
|
||||
)
|
||||
ws.on('close', () => { watchers.close() })
|
||||
ws.on('error', () => { watchers.close() })
|
||||
ws.on('message', (data) => {
|
||||
void handleFsWatchFrame(ctx, ws, watchers, sessionId, data, fenceEnabled)
|
||||
})
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one watch frame.
|
||||
* @param ctx - host plugin context.
|
||||
* @param ws - the owning socket.
|
||||
* @param watchers - the socket's watcher set.
|
||||
* @param sessionId - the session the socket was opened for.
|
||||
* @param data - the raw frame text.
|
||||
* @param fenceEnabled - whether the workspace containment fence is on.
|
||||
*/
|
||||
async function handleFsWatchFrame(
|
||||
ctx: Context,
|
||||
ws: WebSocket,
|
||||
watchers: DirectoryWatchers,
|
||||
sessionId: string,
|
||||
data: unknown,
|
||||
fenceEnabled: () => boolean,
|
||||
): Promise<void> {
|
||||
let frame: FsWatchFrame
|
||||
try {
|
||||
frame = JSON.parse(typeof data === 'string' ? data : String(data)) as FsWatchFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const path = typeof frame.path === 'string' ? frame.path : undefined
|
||||
if (path === undefined || path === '') return
|
||||
try {
|
||||
const cwd = await sessionCwdOf(ctx, sessionId)
|
||||
const dir = await ensureWorkspacePath(cwd, path, fenceEnabled())
|
||||
if (frame.op === 'unwatch') {
|
||||
watchers.remove(dir)
|
||||
return
|
||||
}
|
||||
if (frame.op !== 'watch') return
|
||||
const ok = watchers.add(dir)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ dir, ok }))
|
||||
} catch (error) {
|
||||
// The tree keeps working without live refresh; a refused path is reported
|
||||
// once so the client stops asking for it.
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ dir: path, ok: false, reason: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Push queued `sidebar_open` requests for one session to a connected view. */
|
||||
async function attachAgentOpen(
|
||||
registry: AgentOpenRegistry,
|
||||
|
||||
Reference in New Issue
Block a user