feat: sidebar_open tool gates model-driven sidebar opens behind a global setting
New global Side card setting `agentOpenTools` (default off, 常规 group): when enabled, one `sidebar_open` tool is injected into the model so it can actively open a local file, a local folder (tree rooted there), or an HTTP(S) page in the calling session's sidebar. - host: AgentOpenRegistry + registerOpenTool (src/agent-opens.ts); the tool classifies http(s) URLs / stat-driven file/folder paths, refuses other schemes, resolves relative paths against the session cwd, and reports target-tab-disabled instead of silently no-oping. Gated like agentTerminalTools (register/drain on settings watch). - delivery: /sidebar/ws/agent-opens push (consume-on-send queue, replay on attach); the tool result reports `delivered` so queued opens for non-active sessions are honest. - client: Sidebar subscribes and routes pushes to editor (file), folder window (meta.dir, tree rooted at the folder — EditorHost), or browser (URL) tabs; settings row in 常规 + 20 locale dictionaries. - tests: agent-opens registry/tool spec, smoke gating + WS route list, prefs/plugin-shape defaults, editor-host folder window, side-card rows. - docs: design doc, AGENTS.md key lists + reference, README feature bullets.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* The model-facing `sidebar_open` tool and its delivery registry.
|
||||
*
|
||||
* One tool lets the model actively open a local file, a local folder (as a
|
||||
* tree rooted there), or an HTTP(S) page in the CALLING session's sidebar.
|
||||
* Mirroring the agent-terminal tools, the tool binds to the calling agent's
|
||||
* session through `exec.agent.session.id` — the model never passes a
|
||||
* sessionId, and opens for non-active sessions are queued until that
|
||||
* session's sidebar view is next connected.
|
||||
*
|
||||
* Delivery is a host→browser push over the dedicated `/sidebar/ws/agent-opens`
|
||||
* endpoint (the same pattern as `/sidebar/ws/agent-terminals`): the registry
|
||||
* keeps a per-session queue; a push is consumed on send (`delivered: true`
|
||||
* means a sidebar view was attached at call time), otherwise the request
|
||||
* stays queued and is replayed when a view for that session attaches.
|
||||
*
|
||||
* Conventions (per plugin-development-guide.md §3):
|
||||
* C1 — parameters schema-validated before `execute` runs.
|
||||
* C4 — `execute` returns one canonical JSON value; `render` is a separate
|
||||
* pure text projection.
|
||||
* C6 — `exec.signal.throwIfAborted()` before any fs work.
|
||||
* C10 — no UI/transport vocabulary in the canonical value.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Context } from './context-types.ts'
|
||||
import type { SidebarPrefs } from './prefs-shared.ts'
|
||||
|
||||
/** What the model asked to open. */
|
||||
export type AgentOpenKind = 'file' | 'folder' | 'url'
|
||||
|
||||
/** One pending/broadcast open request (the wire face over the push socket). */
|
||||
export interface AgentOpenRequest {
|
||||
/** Opaque id (host-generated; the client ignores it beyond dedupe/debug). */
|
||||
id: string
|
||||
/** The session whose sidebar the open is targeted at. */
|
||||
sessionId: string
|
||||
kind: AgentOpenKind
|
||||
/** Absolute local path (file/folder) or http(s) URL. */
|
||||
target: string
|
||||
/** Tab title the client should use (basename / hostname / caller-supplied). */
|
||||
title: string
|
||||
}
|
||||
|
||||
/** One subscribed sidebar view's sender. */
|
||||
type Sender = (request: AgentOpenRequest) => void
|
||||
|
||||
/**
|
||||
* Per-session queue of open requests plus the connected sidebar views.
|
||||
*
|
||||
* Lifecycle: `enqueue` adds a request and — when at least one view for the
|
||||
* session is attached — pushes it immediately and removes it from the queue
|
||||
* (consume-on-send: a reconnect must never replay an open the client already
|
||||
* applied, and the browser tab type has no per-URL dedupe, so replaying
|
||||
* would mint duplicate tabs). With no attached view the request stays queued
|
||||
* and `attach` replays it on connect. `drainAll` drops every queued request
|
||||
* (the feature was turned off); `dispose` also drops every subscriber.
|
||||
*/
|
||||
export class AgentOpenRegistry {
|
||||
private pending = new Map<string, AgentOpenRequest[]>()
|
||||
private subscribers = new Map<string, Set<Sender>>()
|
||||
|
||||
/** Queue one open and deliver it immediately when a view is attached.
|
||||
* @returns the request id and whether a connected view received it now. */
|
||||
enqueue(sessionId: string, kind: AgentOpenKind, target: string, title: string): { id: string; delivered: boolean } {
|
||||
const request: AgentOpenRequest = { id: randomUUID(), sessionId, kind, target, title }
|
||||
const list = this.pending.get(sessionId) ?? []
|
||||
list.push(request)
|
||||
this.pending.set(sessionId, list)
|
||||
const views = this.subscribers.get(sessionId)
|
||||
if (views !== undefined && views.size > 0) {
|
||||
for (const send of views) send(request)
|
||||
this.pending.delete(sessionId)
|
||||
return { id: request.id, delivered: true }
|
||||
}
|
||||
return { id: request.id, delivered: false }
|
||||
}
|
||||
|
||||
/** Attach one sidebar view (replays queued requests; consume-on-send).
|
||||
* @returns the disposer detaching the view. */
|
||||
attach(sessionId: string, send: Sender): () => void {
|
||||
let views = this.subscribers.get(sessionId)
|
||||
if (views === undefined) {
|
||||
views = new Set()
|
||||
this.subscribers.set(sessionId, views)
|
||||
}
|
||||
views.add(send)
|
||||
const queued = this.pending.get(sessionId) ?? []
|
||||
if (queued.length > 0) {
|
||||
for (const request of queued) send(request)
|
||||
this.pending.delete(sessionId)
|
||||
}
|
||||
return () => {
|
||||
const current = this.subscribers.get(sessionId)
|
||||
current?.delete(send)
|
||||
if (current !== undefined && current.size === 0) this.subscribers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every queued request (the feature was turned off mid-session). */
|
||||
drainAll(): void {
|
||||
this.pending.clear()
|
||||
}
|
||||
|
||||
/** Drop the queue and every subscriber (plugin teardown). */
|
||||
dispose(): void {
|
||||
this.pending.clear()
|
||||
this.subscribers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the calling agent or throw the canonical "no agent" error. */
|
||||
function requireAgent(agent: ToolRunContext['agent']): NonNullable<ToolRunContext['agent']> {
|
||||
if (agent === undefined) {
|
||||
throw new Error('sidebar_open requires an initiating agent')
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Resolve the calling agent's session id (the queue scope + ownership key). */
|
||||
function sessionIdOf(exec: ToolRunContext): string {
|
||||
return requireAgent(exec.agent).session.id
|
||||
}
|
||||
|
||||
/** Pure text projection helper (the canonical value is already structured). */
|
||||
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
|
||||
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
|
||||
}
|
||||
|
||||
/** Classify a raw target: http(s) URL or a local path (stat-driven). */
|
||||
async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOpenKind; target: string; title: string }> {
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
throw new Error(`"${raw}" is not a valid URL`)
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('sidebar_open only accepts http:// and https:// URLs')
|
||||
}
|
||||
const title = parsed.hostname !== '' ? parsed.hostname : raw
|
||||
return { kind: 'url', target: raw, title }
|
||||
}
|
||||
// Any other scheme (`file:`, `javascript:`, `vscode:`, ...) is refused —
|
||||
// the model must use the plain URL form or a local path. A Windows drive
|
||||
// prefix (`C:\` / `C:/`) is a PATH, not a scheme, and must not be caught.
|
||||
if (!isWindowsDrivePrefix(raw) && /^[a-z][a-z0-9+.-]*:/i.test(raw)) {
|
||||
throw new Error('sidebar_open only accepts http:// and https:// URLs; use a local path for files')
|
||||
}
|
||||
const target = resolve(isAbsolute(raw) ? raw : join(cwd, raw))
|
||||
let info
|
||||
try {
|
||||
info = await stat(target)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`)
|
||||
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`)
|
||||
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
const title = basenameOf(target)
|
||||
return { kind: info.isDirectory() ? 'folder' : 'file', target, title: title === '' ? raw : title }
|
||||
}
|
||||
|
||||
/** The last path segment (mirror of the client's FileTree baseName). */
|
||||
function basenameOf(path: string): string {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||
}
|
||||
|
||||
/** Whether a raw target starts with a Windows drive prefix (`C:\` / `C:/`). */
|
||||
function isWindowsDrivePrefix(raw: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `sidebar_open` tool against the host tool registry. The tool
|
||||
* is gated by the side-card setting `agentOpenTools` (the caller registers
|
||||
* and unregisters it); `readPrefs` supplies the live prefs so a disabled
|
||||
* target tab type (editor/browser) is reported to the model instead of
|
||||
* silently no-oping on the client. `resolveCwd` threads the calling
|
||||
* session's live cwd so relative paths resolve the same way the sidebar's
|
||||
* own routes do.
|
||||
* @param ctx - host plugin context (carries the tools service).
|
||||
* @param registry - the open-request registry (per-session queue + views).
|
||||
* @param resolveCwd - live cwd resolver for one session id.
|
||||
* @param readPrefs - live resolved side card prefs (for tab enable gates).
|
||||
* @returns a disposer that unregisters the tool.
|
||||
*/
|
||||
export function registerOpenTool(
|
||||
ctx: Context,
|
||||
registry: AgentOpenRegistry,
|
||||
resolveCwd: (sessionId: string) => string,
|
||||
readPrefs: () => SidebarPrefs,
|
||||
): () => void {
|
||||
return ctx.tools.register(defineTool({
|
||||
name: 'sidebar_open',
|
||||
description:
|
||||
'Open a local file, a local folder, or an HTTP(S) page in the sidebar of the calling conversation. '
|
||||
+ 'A file opens in the sidebar editor (per-path dedupe: an already-open file is focused); a folder opens a file window whose tree is rooted at that folder; '
|
||||
+ 'a URL opens in the sidebar browser (sandboxed iframe). '
|
||||
+ 'The panel auto-expands for content opens and the tab title defaults to the file/folder name or the URL hostname. '
|
||||
+ 'The path may be absolute or relative to the session working directory. '
|
||||
+ 'The open lands in the CALLING session\'s sidebar: while that session\'s sidebar view is not connected '
|
||||
+ '(e.g. the session is not the active one), the open is queued and delivered when the session sidebar is next shown — '
|
||||
+ 'the result reports `delivered` so you know whether it is visible right now. '
|
||||
+ 'The side card setting "model opens files/folders/pages in the sidebar" must be on, and the target tab type must be enabled in that session\'s settings.',
|
||||
parameters: {
|
||||
target: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Absolute or session-cwd-relative local path, or an http:// / https:// URL.',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Optional tab title (defaults to the file/folder name or the URL hostname).',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, description: 'What was opened: file | folder | url.' },
|
||||
target: { type: 'string', required: true, description: 'The absolute path or URL the open was requested for.' },
|
||||
title: { type: 'string', required: true, description: 'The tab title used (provided title, basename, or hostname).' },
|
||||
delivered: {
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
description: 'Whether the open was pushed to a connected sidebar at call time (false = queued until the session sidebar is next shown).',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: textRender((v: { kind: AgentOpenKind; target: string; title: string; delivered: boolean }) =>
|
||||
v.delivered
|
||||
? `Opened ${v.kind} "${v.title}" (${v.target}) in the sidebar.`
|
||||
: `Requested opening ${v.kind} "${v.title}" (${v.target}) in the sidebar — the session sidebar is not connected yet, so the open is queued and will appear when it is next shown.`,
|
||||
),
|
||||
},
|
||||
execute: async (args: { target: string; title?: string }, exec: ToolRunContext) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
const cwd = resolveCwd(sessionId)
|
||||
const { kind, target, title: defaultTitle } = await classifyTarget(args.target, cwd)
|
||||
// A disabled target tab type would make the client no-op the open:
|
||||
// report the real cause to the model instead of a silent success.
|
||||
const prefs = readPrefs()
|
||||
const tab = kind === 'url' ? 'browser' : 'editor'
|
||||
if (prefs.tabsEnabled[tab] === false) {
|
||||
throw new Error(`the built-in ${tab} tab is disabled in the side card settings; ask the user to enable it (or disable this tool)`)
|
||||
}
|
||||
const title = args.title !== undefined && args.title.trim() !== '' ? args.title : defaultTitle
|
||||
const { delivered } = registry.enqueue(sessionId, kind, target, title)
|
||||
return { kind, target, title, delivered }
|
||||
},
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user