feat: add DSH IDE open-file references

This commit is contained in:
2026-08-17 11:52:48 +08:00
commit d96c11ae3d
42 changed files with 6991 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules/
.pnpm-store/
coverage/
*.log
.DS_Store
lib/
dist/
*.tsbuildinfo
*.vsix
.vs/
[Bb]in/
[Oo]bj/
TestResults/
*.user
*.suo
*.nupkg
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Prophet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+45
View File
@@ -0,0 +1,45 @@
# DSH Coop with Visual Studio
A Windows-local integration that lets DeepSeek Harness users type `@`, choose a file already open in VS Code or Visual Studio, and send that file path to the model as a structured reference.
## Repository layout
- `protocol/`: versioned JSON snapshot contract shared by all components.
- `dsh-plugin/`: dual-face Cordis package. The Host reads snapshots; the Client registers the `@` source.
- `vscode-extension/`: VS Code companion using `window.tabGroups.all`.
- `visualstudio-extension/`: Visual Studio 2022 VSIX using `IVsRunningDocumentTable`.
The companions publish paths and dirty flags only. They never copy source text. DSH and the model continue to use the normal filesystem tools, so an unsaved IDE buffer is explicitly marked but is not silently substituted for the disk file.
## Build
Prerequisites: Node.js 20+, pnpm 10+, .NET SDK/MSBuild with the Visual Studio 2022 extension workload.
```powershell
pnpm install
pnpm check
pnpm build:vs
```
Component-specific installation and development instructions live in each directory.
## Install into DSH
Build the DSH package, then install its local profile bundle into the Web profile:
```powershell
pnpm --filter ./dsh-plugin build
dsh plugin --profile web add F:\dsh-plugin-coop-with-vs\dsh-plugin
```
The package's `cordis.patch.yml` inserts its own shared Host/Client row. Restart `dsh web` after the first install. During development, run the DSH package watcher and the Harness `pnpm run dev:web` watcher when Client HMR is required.
Install the generated VS Code `.vsix` and Visual Studio `.vsix` from their component output directories. Once either IDE companion is active, type `@` in the DSH composer and select from the `IDE open files` group.
## Data and security boundary
Snapshots are stored under `%LOCALAPPDATA%\Prophet\dsh-plugin-coop-with-vs\v1\instances`. Writers update atomically and heartbeat every 15 seconds; the Host ignores snapshots older than 60 seconds. The DSH endpoint accepts loopback GET requests only, validates all snapshot fields, limits results, and never reads file contents while producing candidates.
## License
MIT
+27
View File
@@ -0,0 +1,27 @@
# DSH Cordis plugin
This package is both a normal dual-face Cordis plugin and a DSH profile bundle.
- Host: reads and validates companion snapshots, then serves `GET /coop-with-vs/open-files` to loopback clients.
- Client: registers the `ide-file` source in the existing `@` input-trigger pipeline.
- Pick result: a native composer reference chip whose model projection is `<ide-open-file>{"path":"..."}</ide-open-file>`.
No source content crosses the companion protocol or candidate endpoint.
## Build and test
```powershell
pnpm install
pnpm test
pnpm build
```
`lib/client.js` is wrapped in the `window.__ModuleLoader__.load` format expected by DSH Client Modules.
## Install
```powershell
dsh plugin --profile web add F:\dsh-plugin-coop-with-vs\dsh-plugin
```
The profile manager recognizes `dsh.bundle.patch` and appends this package to the Web profile's bundle list. `cordis.patch.yml` inserts one shared `coop-with-vs` row. Restart DSH after first installation.
+4
View File
@@ -0,0 +1,4 @@
# Profile bundle patch. The package's dsh.client manifest contributes its browser half.
- insert:
- id: coop-with-vs
name: dsh-plugin-coop-with-vs
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@prophet/dsh-plugin-coop-with-vs",
"version": "0.1.0",
"description": "Use files open in VS Code and Visual Studio as DSH @ references",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"repository": { "type": "git", "url": "https://www.prophetk.icu/Prophet/dsh-plugin-coop-with-vs.git", "directory": "dsh-plugin" },
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client.d.ts", "default": "./lib/client.js" },
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
"files": ["lib/index.js", "lib/client.js", "lib/types/**/*.d.ts", "cordis.patch.yml", "README.md"],
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "inject": ["@deepseek-ai/dsh-client-ui-input-trigger"], "platform": "web" }
},
"scripts": {
"build": "node scripts/build.mjs && tsc -p tsconfig.build.json",
"test": "vitest run",
"watch": "node scripts/build.mjs --watch"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-client-ui-input-trigger": ">=0.1.0-rc.5 <0.2.0",
"@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.5 <0.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "4.0.1",
"@deepseek-ai/dsh-client-ui-input-trigger": "0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.6",
"@types/node": "^22.13.4",
"esbuild": "^0.25.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"license": "MIT"
}
+40
View File
@@ -0,0 +1,40 @@
import { build } from 'esbuild'
import { mkdir, rm, writeFile } from 'node:fs/promises'
const watch = process.argv.includes('--watch')
const packageId = '@prophet/dsh-plugin-coop-with-vs'
await rm('lib', { recursive: true, force: true })
await mkdir('lib', { recursive: true })
const common = {
bundle: true,
sourcemap: true,
target: 'es2022',
logLevel: 'info',
}
await build({
...common,
entryPoints: ['src/index.ts'],
outfile: 'lib/index.js',
platform: 'node',
format: 'esm',
packages: 'external',
})
const client = await build({
...common,
entryPoints: ['src/client.ts'],
platform: 'browser',
format: 'cjs',
packages: 'external',
write: false,
})
const body = client.outputFiles[0].text
const wrapped = `window.__ModuleLoader__.load({\n id: ${JSON.stringify(packageId)},\n factory: (require) => {\n var module = { exports: {} };\n var exports = module.exports;\n${body}\n return module.exports;\n },\n});\n`
await writeFile('lib/client.js', wrapped, 'utf8')
if (watch) {
console.error('Watch mode performs one deterministic rebuild; rerun via your file watcher.')
}
+98
View File
@@ -0,0 +1,98 @@
import { basename, isAbsolute } from 'node:path'
import type { IdeSnapshot, OpenFileItem, OpenFileResponse, SnapshotDocument } from './protocol.js'
const MAX_TEXT = 32_768
const MAX_DOCUMENTS = 10_000
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
const keys = new Set(allowed)
return Object.keys(value).every((key) => keys.has(key))
}
function boundedString(value: unknown, max = MAX_TEXT): value is string {
return typeof value === 'string' && value.length > 0 && value.length <= max && !value.includes('\0')
}
function parseDocument(value: unknown): SnapshotDocument | undefined {
if (!isRecord(value) || !hasOnlyKeys(value, ['path', 'label', 'dirty']) || !boundedString(value.path) || !isAbsolute(value.path)) return undefined
if (!boundedString(value.label, 1024) || typeof value.dirty !== 'boolean') return undefined
return { path: value.path, label: value.label, dirty: value.dirty }
}
export function parseSnapshot(value: unknown, now = Date.now(), maxAgeMs = 60_000): IdeSnapshot | undefined {
if (!isRecord(value) || !hasOnlyKeys(value, ['version', 'instanceId', 'ide', 'processId', 'workspace', 'updatedAt', 'documents']) || value.version !== 1) return undefined
if (!boundedString(value.instanceId, 128)) return undefined
if (value.ide !== 'vscode' && value.ide !== 'visualstudio') return undefined
if (!Number.isSafeInteger(value.processId) || (value.processId as number) <= 0) return undefined
if (value.workspace !== undefined && !boundedString(value.workspace)) return undefined
if (!boundedString(value.updatedAt, 128) || !Array.isArray(value.documents) || value.documents.length > MAX_DOCUMENTS) return undefined
const updated = Date.parse(value.updatedAt)
if (!Number.isFinite(updated) || now - updated > maxAgeMs || updated - now > 300_000) return undefined
const documents: SnapshotDocument[] = []
const seen = new Set<string>()
for (const raw of value.documents) {
const document = parseDocument(raw)
if (!document) continue
const key = document.path.toLocaleLowerCase('en-US')
if (seen.has(key)) continue
seen.add(key)
documents.push(document)
}
return {
version: 1,
instanceId: value.instanceId,
ide: value.ide,
processId: value.processId as number,
...(value.workspace === undefined ? {} : { workspace: value.workspace }),
updatedAt: value.updatedAt,
documents,
}
}
function score(item: OpenFileItem, query: string): number {
if (!query) return 0
const q = query.toLocaleLowerCase('en-US')
const label = item.label.toLocaleLowerCase('en-US')
const path = item.path.toLocaleLowerCase('en-US')
if (label === q) return 0
if (label.startsWith(q)) return 1
if (label.includes(q)) return 2
if (path.includes(q)) return 3
return 10
}
export function combineSnapshots(snapshots: readonly IdeSnapshot[], query = '', limit = 100): OpenFileResponse {
const normalizedQuery = query.trim().slice(0, 256)
const byPath = new Map<string, OpenFileItem>()
for (const snapshot of snapshots) {
for (const document of snapshot.documents) {
const key = document.path.toLocaleLowerCase('en-US')
const current = byPath.get(key)
const next: OpenFileItem = {
...document,
ide: snapshot.ide,
instanceId: snapshot.instanceId,
...(snapshot.workspace === undefined ? {} : { workspace: snapshot.workspace }),
instanceOpenCount: snapshot.documents.length,
}
if (!current || (!current.dirty && next.dirty)) byPath.set(key, next)
}
}
const matching = [...byPath.values()].filter((item) => score(item, normalizedQuery) < 10)
matching.sort((a, b) => score(a, normalizedQuery) - score(b, normalizedQuery)
|| a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|| a.path.localeCompare(b.path, undefined, { sensitivity: 'base' }))
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? Math.floor(limit) : 100, 200))
return { total: matching.length, instances: snapshots.length, items: matching.slice(0, safeLimit) }
}
export function displayLabel(path: string): string {
return basename(path) || path
}
+73
View File
@@ -0,0 +1,73 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
InputTriggerCandidate,
InputTriggerPick,
InputTriggerSource,
ReferenceInsert,
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { OpenFileItem, OpenFileResponse } from './protocol.js'
interface IdeCandidate extends InputTriggerCandidate {
path: string
label: string
}
function isOpenFileItem(value: unknown): value is OpenFileItem {
if (typeof value !== 'object' || value === null) return false
const item = value as Partial<OpenFileItem>
return typeof item.path === 'string' && item.path.length > 0
&& typeof item.label === 'string' && item.label.length > 0
&& (item.ide === 'vscode' || item.ide === 'visualstudio')
&& typeof item.instanceOpenCount === 'number'
&& typeof item.dirty === 'boolean'
}
function ideName(ide: OpenFileItem['ide']): string {
return ide === 'vscode' ? 'VS Code' : 'Visual Studio'
}
export function candidateFromItem(item: OpenFileItem): IdeCandidate {
const details = [ideName(item.ide), item.workspace, `${item.instanceOpenCount} open`, item.dirty ? 'unsaved changes' : undefined]
.filter((value): value is string => Boolean(value))
return { name: item.label, description: details.join(' 璺?'), hint: item.path, path: item.path, label: item.label }
}
export function serializeIdeReference(path: string): string {
return `<ide-open-file>${JSON.stringify({ path })}</ide-open-file>`
}
export function referenceFromPick(pick: InputTriggerPick): ReferenceInsert | undefined {
const candidate = pick.candidate as Partial<IdeCandidate>
if (typeof candidate.path !== 'string' || typeof candidate.label !== 'string') return undefined
return { source: 'ide-file', ref: candidate.path, label: candidate.label, clipboardText: candidate.path }
}
export const inject = ['inputTriggers']
export function apply(ctx: Context): void {
const source: InputTriggerSource = {
trigger: '@',
name: 'ide-file',
order: 20,
async candidates(_session, request) {
const params = new URLSearchParams({ q: request.query, limit: '100' })
const response = await fetch(`/coop-with-vs/open-files?${params}`, {
method: 'GET',
signal: request.signal,
headers: { accept: 'application/json' },
})
if (!response.ok) return []
const payload = await response.json() as Partial<OpenFileResponse>
return Array.isArray(payload.items) ? payload.items.filter(isOpenFileItem).map(candidateFromItem) : []
},
onPick(pick) {
const insert = referenceFromPick(pick)
return insert ? { insert } : undefined
},
codec: {
clipboardText: (path) => path,
serialize: (path) => Promise.resolve(serializeIdeReference(path)),
},
}
ctx.effect(() => ctx.inputTriggers.registerSource(source), 'coop-with-vs: IDE open files')
}
+79
View File
@@ -0,0 +1,79 @@
import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
import { combineSnapshots, parseSnapshot } from './catalog.js'
import type { IdeSnapshot } from './protocol.js'
const ROUTE = '/coop-with-vs/open-files'
interface HostContext extends Context {
webServer: WebServer
}
export function snapshotDirectory(localAppData = process.env.LOCALAPPDATA): string | undefined {
return localAppData ? join(localAppData, 'Prophet', 'dsh-plugin-coop-with-vs', 'v1', 'instances') : undefined
}
export function isLoopbackAddress(address: string | undefined): boolean {
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
}
export async function loadSnapshots(directory: string | undefined, now = Date.now()): Promise<IdeSnapshot[]> {
if (!directory) return []
let names: string[]
try {
names = await readdir(directory)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
const snapshots: IdeSnapshot[] = []
await Promise.all(names.filter((name) => /^(vscode|visualstudio)-.+\.json$/i.test(name)).map(async (name) => {
try {
const text = await readFile(join(directory, name), 'utf8')
if (text.length > 8 * 1024 * 1024) return
const snapshot = parseSnapshot(JSON.parse(text) as unknown, now)
if (snapshot) snapshots.push(snapshot)
} catch {
// One partially written or stale instance must not hide healthy IDEs.
}
}))
return snapshots
}
export const inject = ['webServer']
export function apply(ctx: HostContext): void {
const directory = snapshotDirectory()
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: ROUTE,
async handler(req, res) {
if (!isLoopbackAddress(req.socket.remoteAddress)) {
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify({ error: 'loopback-required' }))
return
}
if (req.method !== 'GET') {
res.writeHead(405, { allow: 'GET', 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify({ error: 'method-not-allowed' }))
return
}
try {
const url = new URL(req.url ?? ROUTE, 'http://127.0.0.1')
const query = (url.searchParams.get('q') ?? '').slice(0, 256)
const requestedLimit = Number(url.searchParams.get('limit') ?? '100')
const snapshots = await loadSnapshots(directory)
const result = combineSnapshots(snapshots, query, requestedLimit)
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(result))
} catch {
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify({ error: 'snapshot-read-failed' }))
}
},
}))
}
+30
View File
@@ -0,0 +1,30 @@
export type IdeKind = 'vscode' | 'visualstudio'
export interface SnapshotDocument {
path: string
label: string
dirty: boolean
}
export interface IdeSnapshot {
version: 1
instanceId: string
ide: IdeKind
processId: number
workspace?: string
updatedAt: string
documents: SnapshotDocument[]
}
export interface OpenFileItem extends SnapshotDocument {
ide: IdeKind
instanceId: string
workspace?: string
instanceOpenCount: number
}
export interface OpenFileResponse {
total: number
instances: number
items: OpenFileItem[]
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { combineSnapshots, parseSnapshot } from '../src/catalog.js'
const now = Date.parse('2026-01-01T00:00:30.000Z')
function snapshot(path: string, overrides: Record<string, unknown> = {}) {
return {
version: 1,
instanceId: 'vscode-a',
ide: 'vscode',
processId: 10,
workspace: 'F:\\project',
updatedAt: '2026-01-01T00:00:00.000Z',
documents: [{ path, label: 'main.ts', dirty: false }],
...overrides,
}
}
describe('parseSnapshot', () => {
it('accepts valid absolute files', () => {
expect(parseSnapshot(snapshot('F:\\project\\main.ts'), now)?.documents).toHaveLength(1)
})
it('rejects stale and unknown versions', () => {
expect(parseSnapshot(snapshot('F:\\project\\main.ts'), now + 61_000)).toBeUndefined()
expect(parseSnapshot(snapshot('F:\\project\\main.ts', { version: 2 }), now)).toBeUndefined()
})
it('rejects unknown v1 fields', () => {
expect(parseSnapshot(snapshot('F:\\\\project\\\\main.ts', { extra: true }), now)).toBeUndefined()
})
it('drops invalid and duplicate documents without dropping the instance', () => {
const value = snapshot('F:\\project\\main.ts', {
documents: [
{ path: 'relative.ts', label: 'relative.ts', dirty: false },
{ path: 'F:\\project\\main.ts', label: 'main.ts', dirty: false },
{ path: 'f:\\PROJECT\\MAIN.ts', label: 'MAIN.ts', dirty: true },
],
})
expect(parseSnapshot(value, now)?.documents).toHaveLength(1)
})
})
describe('combineSnapshots', () => {
it('deduplicates paths and prefers dirty metadata', () => {
const clean = parseSnapshot(snapshot('F:\\project\\main.ts'), now)!
const dirty = parseSnapshot(snapshot('f:\\PROJECT\\main.ts', {
instanceId: 'visualstudio-b', ide: 'visualstudio', documents: [{ path: 'f:\\PROJECT\\main.ts', label: 'main.ts', dirty: true }],
}), now)!
const result = combineSnapshots([clean, dirty])
expect(result.total).toBe(1)
expect(result.items[0]?.dirty).toBe(true)
})
it('matches basename before path and caps the result', () => {
const parsed = parseSnapshot(snapshot('F:\\project\\main.ts', {
documents: [
{ path: 'F:\\project\\src\\main.ts', label: 'main.ts', dirty: false },
{ path: 'F:\\project\\main-helper.ts', label: 'main-helper.ts', dirty: false },
],
}), now)!
expect(combineSnapshots([parsed], 'main', 1).items).toHaveLength(1)
expect(combineSnapshots([parsed], 'main', 10).total).toBe(2)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { candidateFromItem, serializeIdeReference } from '../src/client.js'
const item = {
path: 'F:\\project\\a&b.ts',
label: 'a&b.ts',
dirty: true,
ide: 'vscode' as const,
instanceId: 'one',
workspace: 'F:\\project',
instanceOpenCount: 4,
}
describe('client reference projection', () => {
it('keeps the path in a structured JSON payload', () => {
expect(serializeIdeReference(item.path)).toBe('<ide-open-file>{"path":"F:\\\\project\\\\a&b.ts"}</ide-open-file>')
})
it('shows IDE, count and dirty state', () => {
const candidate = candidateFromItem(item)
expect(candidate.description).toContain('VS Code')
expect(candidate.description).toContain('4 open')
expect(candidate.description).toContain('unsaved changes')
})
})
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "lib/types"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "dsh-plugin-coop-with-vs-workspace",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@10.4.1",
"scripts": {
"build": "corepack pnpm --filter ./dsh-plugin build && corepack pnpm --filter ./vscode-extension build",
"test": "corepack pnpm --filter ./dsh-plugin test && corepack pnpm --filter ./vscode-extension test",
"check": "corepack pnpm test && corepack pnpm build",
"build:vs": "dotnet build visualstudio-extension/CoopWithDsh.sln -c Release",
"test:vs": "dotnet test visualstudio-extension/CoopWithDsh.sln -c Release --no-restore"
}
}
+5643
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- dsh-plugin
- vscode-extension
+23
View File
@@ -0,0 +1,23 @@
# Open-file snapshot protocol v1
IDE companions publish one JSON file per running IDE instance in:
```text
%LOCALAPPDATA%\Prophet\dsh-plugin-coop-with-vs\v1\instances
```
The schema is [`open-files-v1.schema.json`](open-files-v1.schema.json).
## Publishing rules
- File name: `vscode-<instanceId>.json` or `visualstudio-<instanceId>.json`.
- Write a temporary file in the same directory, then atomically replace/rename it.
- Refresh `updatedAt` at least every 15 seconds, even when tabs do not change.
- Delete the instance file on clean shutdown. Consumers expire snapshots older than 60 seconds because shutdown cleanup is best effort.
- Publish only absolute paths for local, disk-backed files. Do not publish source text, credentials, virtual documents, or untitled buffers.
- Deduplicate paths case-insensitively on Windows.
- `dirty: true` means the IDE buffer may differ from the on-disk file. DSH still reads the on-disk version unless a future protocol explicitly adds buffer transfer.
## Compatibility
Consumers reject unknown versions and malformed entries without rejecting healthy snapshots from other instances. Adding fields requires a new protocol version because v1 intentionally rejects additional properties.
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"instanceId": "sample-visualstudio-instance",
"ide": "visualstudio",
"processId": 5678,
"workspace": "F:\\project\\App.sln",
"updatedAt": "2026-01-01T00:00:00.000Z",
"documents": [
{ "path": "F:\\project\\Program.cs", "label": "Program.cs", "dirty": true }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"instanceId": "sample-vscode-instance",
"ide": "vscode",
"processId": 1234,
"workspace": "F:\\project",
"updatedAt": "2026-01-01T00:00:00.000Z",
"documents": [
{ "path": "F:\\project\\src\\main.ts", "label": "main.ts", "dirty": false }
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://www.prophetk.icu/Prophet/dsh-plugin-coop-with-vs/-/raw/main/protocol/open-files-v1.schema.json",
"title": "DSH IDE open-file snapshot",
"type": "object",
"additionalProperties": false,
"required": ["version", "instanceId", "ide", "processId", "updatedAt", "documents"],
"properties": {
"version": { "const": 1 },
"instanceId": { "type": "string", "minLength": 1, "maxLength": 128 },
"ide": { "enum": ["vscode", "visualstudio"] },
"processId": { "type": "integer", "minimum": 1 },
"workspace": { "type": "string", "maxLength": 32768 },
"updatedAt": { "type": "string", "format": "date-time" },
"documents": {
"type": "array",
"maxItems": 10000,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "label", "dirty"],
"properties": {
"path": { "type": "string", "minLength": 1, "maxLength": 32768 },
"label": { "type": "string", "minLength": 1, "maxLength": 1024 },
"dirty": { "type": "boolean" }
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prophet.CoopWithDsh", "Prophet.CoopWithDsh\Prophet.CoopWithDsh.csproj", "{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prophet.CoopWithDsh.Tests", "Prophet.CoopWithDsh.Tests\Prophet.CoopWithDsh.Tests.csproj", "{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{54E20963-D686-4F6E-9C76-A9C61AA8A3EE}.Release|Any CPU.Build.0 = Release|Any CPU
{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C1C4B8A-40E4-4FB6-9AE4-ABEB5A22875C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Prophet.CoopWithDsh\SnapshotModels.cs" Link="SnapshotModels.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
using System;
using Xunit;
namespace Prophet.CoopWithDsh.Tests
{
public sealed class SnapshotModelTests
{
[Fact]
public void CreateDeduplicatesPathsAndPreservesDirtyState()
{
var snapshot = SnapshotModel.Create("one", 10, "F:\\Project\\App.sln", new[] {
new OpenDocument { Path = "F:\\Project\\main.cs", Label = "main.cs", Dirty = false },
new OpenDocument { Path = "f:\\project\\MAIN.cs", Label = "MAIN.cs", Dirty = true },
}, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
Assert.Equal(1, snapshot.Version);
Assert.Equal("visualstudio", snapshot.Ide);
Assert.Single(snapshot.Documents);
Assert.True(snapshot.Documents[0].Dirty);
Assert.Equal("2026-01-01T00:00:00.0000000Z", snapshot.UpdatedAt);
}
}
}
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using Microsoft.VisualStudio.TextManager.Interop;
using Task = System.Threading.Tasks.Task;
namespace Prophet.CoopWithDsh
{
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[InstalledProductRegistration("DSH Open File Bridge", "Publishes open file metadata for DeepSeek Harness", "0.1.0")]
[ProvideAutoLoad(UIContextGuids80.NoSolution, PackageAutoLoadFlags.BackgroundLoad)]
[ProvideAutoLoad(UIContextGuids80.SolutionExists, PackageAutoLoadFlags.BackgroundLoad)]
[Guid(PackageGuidString)]
public sealed class CoopWithDshPackage : AsyncPackage, IVsRunningDocTableEvents
{
public const string PackageGuidString = "1e02d547-499b-41ed-a25e-b356a9687e31";
private const int DebounceMilliseconds = 200;
private const int HeartbeatMilliseconds = 15000;
private IVsRunningDocumentTable? runningDocuments;
private IVsSolution? solution;
private SnapshotPublisher? publisher;
private Timer? debounceTimer;
private Timer? heartbeatTimer;
private uint eventsCookie;
private bool disposing;
protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
{
await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
runningDocuments = await GetServiceAsync(typeof(SVsRunningDocumentTable)) as IVsRunningDocumentTable;
solution = await GetServiceAsync(typeof(SVsSolution)) as IVsSolution;
if (runningDocuments == null) throw new InvalidOperationException("Visual Studio Running Document Table is unavailable.");
publisher = new SnapshotPublisher();
ErrorHandler.ThrowOnFailure(runningDocuments.AdviseRunningDocTableEvents(this, out eventsCookie));
debounceTimer = new Timer(_ => JoinableTaskFactory.RunAsync(PublishAsync), null, Timeout.Infinite, Timeout.Infinite);
heartbeatTimer = new Timer(_ => JoinableTaskFactory.RunAsync(PublishAsync), null, HeartbeatMilliseconds, HeartbeatMilliseconds);
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
await PublishAsync();
}
private void SchedulePublish()
{
if (!disposing) debounceTimer?.Change(DebounceMilliseconds, Timeout.Infinite);
}
private async Task PublishAsync()
{
if (disposing || publisher == null || runningDocuments == null) return;
await JoinableTaskFactory.SwitchToMainThreadAsync();
var documents = EnumerateDocuments(runningDocuments);
var workspace = ReadSolutionPath(solution);
var snapshot = SnapshotModel.Create(publisher.InstanceId, Process.GetCurrentProcess().Id, workspace, documents, DateTimeOffset.UtcNow);
try { publisher.Publish(snapshot); } catch (Exception error) { Trace.WriteLine($"DSH Open File Bridge publish failed: {error}"); }
}
private static IReadOnlyList<OpenDocument> EnumerateDocuments(IVsRunningDocumentTable table)
{
ThreadHelper.ThrowIfNotOnUIThread();
ErrorHandler.ThrowOnFailure(table.GetRunningDocumentsEnum(out var iterator));
var result = new List<OpenDocument>();
var cookies = new uint[1];
while (iterator.Next(1, cookies, out var fetched) == VSConstants.S_OK && fetched == 1)
{
IntPtr docData = IntPtr.Zero;
try
{
ErrorHandler.ThrowOnFailure(table.GetDocumentInfo(cookies[0], out _, out _, out _, out var moniker, out _, out _, out docData));
if (string.IsNullOrWhiteSpace(moniker) || !Path.IsPathRooted(moniker) || !File.Exists(moniker)) continue;
var dirty = false;
if (docData != IntPtr.Zero)
{
var value = Marshal.GetObjectForIUnknown(docData);
if (value is IVsPersistDocData persist && ErrorHandler.Succeeded(persist.IsDocDataDirty(out var isDirty))) dirty = isDirty != 0;
}
result.Add(new OpenDocument { Path = Path.GetFullPath(moniker), Label = Path.GetFileName(moniker), Dirty = dirty });
}
catch (Exception error) { Trace.WriteLine($"DSH Open File Bridge skipped one document: {error.Message}"); }
finally { if (docData != IntPtr.Zero) Marshal.Release(docData); }
}
return result;
}
private static string? ReadSolutionPath(IVsSolution? solutionService)
{
ThreadHelper.ThrowIfNotOnUIThread();
if (solutionService == null) return null;
return ErrorHandler.Succeeded(solutionService.GetSolutionInfo(out _, out var solutionFile, out _)) && !string.IsNullOrWhiteSpace(solutionFile)
? solutionFile : null;
}
private void OnProcessExit(object sender, EventArgs e) => publisher?.Dispose();
protected override void Dispose(bool disposing)
{
this.disposing = true;
if (disposing)
{
AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
debounceTimer?.Dispose();
heartbeatTimer?.Dispose();
if (runningDocuments != null && eventsCookie != 0)
{
JoinableTaskFactory.Run(async () => {
await JoinableTaskFactory.SwitchToMainThreadAsync();
runningDocuments.UnadviseRunningDocTableEvents(eventsCookie);
});
}
publisher?.Dispose();
}
base.Dispose(disposing);
}
public int OnAfterFirstDocumentLock(uint docCookie, uint lockType, uint readLocks, uint editLocks) { SchedulePublish(); return VSConstants.S_OK; }
public int OnBeforeLastDocumentUnlock(uint docCookie, uint lockType, uint readLocks, uint editLocks) { SchedulePublish(); return VSConstants.S_OK; }
public int OnAfterSave(uint docCookie) { SchedulePublish(); return VSConstants.S_OK; }
public int OnAfterAttributeChange(uint docCookie, uint attributes) { SchedulePublish(); return VSConstants.S_OK; }
public int OnBeforeDocumentWindowShow(uint docCookie, int firstShow, IVsWindowFrame frame) { SchedulePublish(); return VSConstants.S_OK; }
public int OnAfterDocumentWindowHide(uint docCookie, IVsWindowFrame frame) { SchedulePublish(); return VSConstants.S_OK; }
}
}
@@ -0,0 +1,7 @@
MIT License
Copyright (c) 2026 Prophet
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
@@ -0,0 +1,31 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>Prophet.CoopWithDsh</RootNamespace>
<AssemblyName>Prophet.CoopWithDsh</AssemblyName>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<GeneratePkgDefFile>true</GeneratePkgDefFile>
<CreateVsixContainer>true</CreateVsixContainer>
<DeployExtension>false</DeployExtension>
<IncludeAssemblyInVSIXContainer>true</IncludeAssemblyInVSIXContainer>
<IncludeDebugSymbolsInVSIXContainer>false</IncludeDebugSymbolsInVSIXContainer>
<CopyBuildOutputToOutputDirectory>true</CopyBuildOutputToOutputDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.SDK" Version="17.0.31902.203" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.VSSDK.BuildTools" Version="17.9.3168" PrivateAssets="all" GeneratePathProperty="true" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<None Include="source.extension.vsixmanifest" />
<Content Include="LICENSE.txt">
<IncludeInVSIX>true</IncludeInVSIX>
</Content>
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="$(PkgMicrosoft_VSSDK_BuildTools)\tools\vssdk\Microsoft.VsSDK.targets" />
<Target Name="BuildVsix" AfterTargets="Build" DependsOnTargets="CreateVsixContainer" />
</Project>
@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
namespace Prophet.CoopWithDsh
{
internal sealed class OpenDocument
{
[JsonProperty("path")] public string Path { get; set; } = string.Empty;
[JsonProperty("label")] public string Label { get; set; } = string.Empty;
[JsonProperty("dirty")] public bool Dirty { get; set; }
}
internal sealed class OpenFileSnapshot
{
[JsonProperty("version")] public int Version { get; set; } = 1;
[JsonProperty("instanceId")] public string InstanceId { get; set; } = string.Empty;
[JsonProperty("ide")] public string Ide { get; set; } = "visualstudio";
[JsonProperty("processId")] public int ProcessId { get; set; }
[JsonProperty("workspace", NullValueHandling = NullValueHandling.Ignore)] public string? Workspace { get; set; }
[JsonProperty("updatedAt")] public string UpdatedAt { get; set; } = string.Empty;
[JsonProperty("documents")] public IReadOnlyList<OpenDocument> Documents { get; set; } = Array.Empty<OpenDocument>();
}
internal static class SnapshotModel
{
internal static OpenFileSnapshot Create(string instanceId, int processId, string? workspace, IEnumerable<OpenDocument> documents, DateTimeOffset now)
{
var deduplicated = new Dictionary<string, OpenDocument>(StringComparer.OrdinalIgnoreCase);
foreach (var document in documents.Where(d => !string.IsNullOrWhiteSpace(d.Path) && Path.IsPathRooted(d.Path)))
{
if (!deduplicated.TryGetValue(document.Path, out var current) || (!current.Dirty && document.Dirty))
deduplicated[document.Path] = document;
}
return new OpenFileSnapshot
{
InstanceId = instanceId,
ProcessId = processId,
Workspace = string.IsNullOrWhiteSpace(workspace) ? null : workspace,
UpdatedAt = now.UtcDateTime.ToString("O"),
Documents = deduplicated.Values.OrderBy(d => d.Label, StringComparer.OrdinalIgnoreCase).ThenBy(d => d.Path, StringComparer.OrdinalIgnoreCase).ToArray(),
};
}
}
}
@@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Text;
using Newtonsoft.Json;
namespace Prophet.CoopWithDsh
{
internal sealed class SnapshotPublisher : IDisposable
{
private readonly object gate = new object();
internal string InstanceId { get; } = Guid.NewGuid().ToString("N");
internal string FilePath { get; }
internal SnapshotPublisher(string? localAppData = null)
{
var root = localAppData ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(root)) throw new InvalidOperationException("LOCALAPPDATA is unavailable.");
var directory = Path.Combine(root, "Prophet", "dsh-plugin-coop-with-vs", "v1", "instances");
FilePath = Path.Combine(directory, $"visualstudio-{InstanceId}.json");
}
internal void Publish(OpenFileSnapshot snapshot)
{
lock (gate)
{
var directory = Path.GetDirectoryName(FilePath)!;
Directory.CreateDirectory(directory);
var temporary = FilePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
File.WriteAllText(temporary, JsonConvert.SerializeObject(snapshot) + Environment.NewLine, new UTF8Encoding(false));
try
{
if (File.Exists(FilePath)) File.Replace(temporary, FilePath, null);
else File.Move(temporary, FilePath);
}
catch
{
TryDelete(temporary);
throw;
}
}
}
public void Dispose() => TryDelete(FilePath);
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { }
}
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
<Metadata>
<Identity Id="Prophet.CoopWithDsh.1e02d547-499b-41ed-a25e-b356a9687e31" Version="0.1.0" Language="en-US" Publisher="Prophet" />
<DisplayName>DSH Open File Bridge</DisplayName>
<Description xml:space="preserve">Publishes Visual Studio open file metadata for DeepSeek Harness @ references.</Description>
<License>LICENSE.txt</License>
</Metadata>
<Installation>
<InstallationTarget Id="Microsoft.VisualStudio.Community" Version="[17.0,18.0)"><ProductArchitecture>amd64</ProductArchitecture></InstallationTarget>
<InstallationTarget Id="Microsoft.VisualStudio.Pro" Version="[17.0,18.0)"><ProductArchitecture>amd64</ProductArchitecture></InstallationTarget>
<InstallationTarget Id="Microsoft.VisualStudio.Enterprise" Version="[17.0,18.0)"><ProductArchitecture>amd64</ProductArchitecture></InstallationTarget>
</Installation>
<Dependencies />
<Prerequisites>
<Prerequisite Id="Microsoft.VisualStudio.Component.CoreEditor" Version="[17.0,18.0)" DisplayName="Visual Studio core editor" />
</Prerequisites>
<Assets>
<Asset Type="Microsoft.VisualStudio.VsPackage" d:Source="Project" d:ProjectName="Prophet.CoopWithDsh" Path="|Prophet.CoopWithDsh;PkgdefProjectOutputGroup|" />
</Assets>
</PackageManifest>
+17
View File
@@ -0,0 +1,17 @@
# DSH Open File Bridge for Visual Studio 2022
This VSIX is a background-loaded `AsyncPackage`. Each Visual Studio process enumerates its own `IVsRunningDocumentTable`, listens for RDT changes, and publishes one protocol-v1 snapshot. This avoids the ambiguity and integrity-level failures of attaching externally through EnvDTE/ROT.
Only absolute, existing local files are published. Dirty state is read through `IVsPersistDocData`; source buffers are never copied.
## Build and test
Use a Visual Studio Developer PowerShell or a machine with the VS 2022 extension workload:
```powershell
dotnet restore CoopWithDsh.sln
dotnet test CoopWithDsh.sln -c Release
dotnet build Prophet.CoopWithDsh\Prophet.CoopWithDsh.csproj -c Release
```
Install the generated `.vsix` from the Release output, restart Visual Studio, then type `@` in DSH. The snapshot heartbeats every 15 seconds and is removed on normal package/process shutdown.
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 Prophet
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+20
View File
@@ -0,0 +1,20 @@
# DSH Open File Bridge for VS Code
This companion publishes the local disk files represented by all VS Code tabs. It uses `window.tabGroups.all`, so background tabs are included; text diff tabs contribute both local sides. Untitled and non-file URI schemes are excluded.
## Development
```powershell
pnpm install
pnpm test
pnpm build
pnpm package
```
Install the generated `.vsix` with **Extensions: Install from VSIX...** or:
```powershell
code --install-extension prophet-coop-with-dsh-0.1.0.vsix
```
The extension writes no source content. It updates a per-instance JSON snapshot under `%LOCALAPPDATA%\Prophet\dsh-plugin-coop-with-vs\v1\instances`, heartbeats every 15 seconds, and removes the file on clean deactivation.
+28
View File
@@ -0,0 +1,28 @@
{
"name": "prophet-coop-with-dsh",
"displayName": "DSH Open File Bridge",
"description": "Publishes VS Code open file metadata for DeepSeek Harness @ references",
"version": "0.1.0",
"publisher": "prophet",
"license": "MIT",
"repository": { "type": "git", "url": "https://www.prophetk.icu/Prophet/dsh-plugin-coop-with-vs.git", "directory": "vscode-extension" },
"engines": { "vscode": "^1.85.0" },
"categories": ["Other"],
"activationEvents": ["onStartupFinished"],
"main": "./dist/extension.js",
"files": ["dist/**", "README.md", "LICENSE"],
"scripts": {
"build": "node scripts/build.mjs",
"watch": "node scripts/build.mjs --watch",
"test": "vitest run",
"package": "corepack pnpm build && node node_modules/@vscode/vsce/vsce package --no-dependencies"
},
"devDependencies": {
"@types/node": "^22.13.4",
"@types/vscode": "^1.85.0",
"@vscode/vsce": "^3.2.2",
"esbuild": "^0.25.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}
+22
View File
@@ -0,0 +1,22 @@
import * as esbuild from 'esbuild'
const watch = process.argv.includes('--watch')
const options = {
entryPoints: ['src/extension.ts'],
bundle: true,
outfile: 'dist/extension.js',
external: ['vscode'],
format: 'cjs',
platform: 'node',
target: 'node20',
sourcemap: true,
logLevel: 'info',
}
if (watch) {
const context = await esbuild.context(options)
await context.watch()
console.log('Watching VS Code extension sources...')
} else {
await esbuild.build(options)
}
+91
View File
@@ -0,0 +1,91 @@
import { basename } from 'node:path'
import * as vscode from 'vscode'
import { createSnapshot, type OpenDocument } from './model.js'
import { SnapshotPublisher } from './publisher.js'
const HEARTBEAT_MS = 15_000
const DEBOUNCE_MS = 150
function localFile(uri: vscode.Uri): string | undefined {
return uri.scheme === 'file' && uri.fsPath ? uri.fsPath : undefined
}
function tabDocuments(tab: vscode.Tab): OpenDocument[] {
const input = tab.input
const paths: string[] = []
if (input instanceof vscode.TabInputText) {
const path = localFile(input.uri)
if (path) paths.push(path)
} else if (input instanceof vscode.TabInputTextDiff) {
const original = localFile(input.original)
const modified = localFile(input.modified)
if (original) paths.push(original)
if (modified) paths.push(modified)
}
return paths.map((path) => ({ path, label: basename(path), dirty: tab.isDirty }))
}
export function collectOpenDocuments(): OpenDocument[] {
return vscode.window.tabGroups.all.flatMap((group) => group.tabs.flatMap(tabDocuments))
}
function workspaceLabel(): string | undefined {
const workspaceFile = vscode.workspace.workspaceFile
if (workspaceFile?.scheme === 'file') return workspaceFile.fsPath
const folders = vscode.workspace.workspaceFolders
if (folders?.length === 1 && folders[0]?.uri.scheme === 'file') return folders[0].uri.fsPath
return vscode.workspace.name
}
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const output = vscode.window.createOutputChannel('DSH Open File Bridge', { log: true })
const publisher = new SnapshotPublisher()
let debounce: NodeJS.Timeout | undefined
let disposed = false
const publish = async () => {
if (disposed) return
const snapshot = createSnapshot({
instanceId: publisher.instanceId,
processId: process.pid,
workspace: workspaceLabel(),
documents: collectOpenDocuments(),
})
try {
await publisher.publish(snapshot)
output.debug(`Published ${snapshot.documents.length} open files`)
} catch (error) {
output.error(`Snapshot publish failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
const schedule = () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => void publish(), DEBOUNCE_MS)
}
context.subscriptions.push(
output,
vscode.window.tabGroups.onDidChangeTabs(schedule),
vscode.window.onDidChangeActiveTextEditor(schedule),
vscode.workspace.onDidChangeWorkspaceFolders(schedule),
)
const heartbeat = setInterval(() => void publish(), HEARTBEAT_MS)
const onExit = () => publisher.disposeSync()
process.once('exit', onExit)
context.subscriptions.push({
dispose() {
disposed = true
if (debounce) clearTimeout(debounce)
clearInterval(heartbeat)
process.off('exit', onExit)
void publisher.dispose()
},
})
await publish()
}
export function deactivate(): void {
// ExtensionContext disposal owns cleanup.
}
+46
View File
@@ -0,0 +1,46 @@
export type IdeKind = 'vscode'
export interface OpenDocument {
path: string
label: string
dirty: boolean
}
export interface OpenFileSnapshot {
version: 1
instanceId: string
ide: IdeKind
processId: number
workspace?: string
updatedAt: string
documents: OpenDocument[]
}
export function deduplicateDocuments(documents: readonly OpenDocument[]): OpenDocument[] {
const result = new Map<string, OpenDocument>()
for (const document of documents) {
const key = document.path.toLocaleLowerCase('en-US')
const current = result.get(key)
if (!current || (!current.dirty && document.dirty)) result.set(key, document)
}
return [...result.values()].sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|| a.path.localeCompare(b.path, undefined, { sensitivity: 'base' }))
}
export function createSnapshot(input: {
instanceId: string
processId: number
workspace?: string
documents: readonly OpenDocument[]
now?: Date
}): OpenFileSnapshot {
return {
version: 1,
instanceId: input.instanceId,
ide: 'vscode',
processId: input.processId,
...(input.workspace === undefined ? {} : { workspace: input.workspace }),
updatedAt: (input.now ?? new Date()).toISOString(),
documents: deduplicateDocuments(input.documents),
}
}
+47
View File
@@ -0,0 +1,47 @@
import { randomUUID } from 'node:crypto'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { rmSync } from 'node:fs'
import { join } from 'node:path'
import type { OpenFileSnapshot } from './model.js'
export function snapshotRoot(localAppData = process.env.LOCALAPPDATA): string {
if (!localAppData) throw new Error('LOCALAPPDATA is not available')
return join(localAppData, 'Prophet', 'dsh-plugin-coop-with-vs', 'v1', 'instances')
}
export class SnapshotPublisher {
readonly instanceId = randomUUID()
readonly filePath: string
private pending = Promise.resolve()
constructor(private readonly directory = snapshotRoot()) {
this.filePath = join(directory, `vscode-${this.instanceId}.json`)
}
publish(snapshot: OpenFileSnapshot): Promise<void> {
this.pending = this.pending.then(async () => {
await mkdir(this.directory, { recursive: true })
const temporary = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`
await writeFile(temporary, `${JSON.stringify(snapshot)}\n`, { encoding: 'utf8', mode: 0o600 })
try {
await rename(temporary, this.filePath)
} catch (error) {
await rm(this.filePath, { force: true })
await rename(temporary, this.filePath).catch(async (renameError) => {
await rm(temporary, { force: true })
throw renameError
})
}
})
return this.pending
}
async dispose(): Promise<void> {
await this.pending.catch(() => undefined)
await rm(this.filePath, { force: true }).catch(() => undefined)
}
disposeSync(): void {
try { rmSync(this.filePath, { force: true }) } catch { /* best effort during process exit */ }
}
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { createSnapshot, deduplicateDocuments } from '../src/model.js'
describe('deduplicateDocuments', () => {
it('deduplicates Windows paths and preserves dirty state', () => {
const result = deduplicateDocuments([
{ path: 'F:\\Project\\main.ts', label: 'main.ts', dirty: false },
{ path: 'f:\\project\\MAIN.ts', label: 'MAIN.ts', dirty: true },
])
expect(result).toEqual([{ path: 'f:\\project\\MAIN.ts', label: 'MAIN.ts', dirty: true }])
})
})
describe('createSnapshot', () => {
it('creates the frozen v1 wire shape', () => {
const snapshot = createSnapshot({
instanceId: 'one', processId: 12, workspace: 'F:\\Project',
documents: [{ path: 'F:\\Project\\main.ts', label: 'main.ts', dirty: false }],
now: new Date('2026-01-01T00:00:00.000Z'),
})
expect(snapshot).toMatchObject({ version: 1, ide: 'vscode', updatedAt: '2026-01-01T00:00:00.000Z' })
})
})
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"types": ["node", "vscode"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}