dsh-managed-workspaces 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/context.js ADDED
@@ -0,0 +1,78 @@
1
+ // dsh-managed-workspaces: prompt context for sessions inside a managed workspace.
2
+ //
3
+ // The working-directory context already states the current directory; this
4
+ // contribution explains the workspace root, the linked folders, and the two
5
+ // consequences the model cannot discover by itself: search and sandbox roots.
6
+
7
+ /**
8
+ * Render the managed-workspace prompt section for one workspace.
9
+ * @param {{name: string, path: string, links: Array<{alias: string, target: string}>}} workspace - workspace facts.
10
+ * @param {string} linksDirectoryName - reserved links directory name.
11
+ * @returns {string} the prompt text.
12
+ */
13
+ export function renderContext(workspace, linksDirectoryName) {
14
+ const links = workspace.links
15
+ const lines = [
16
+ `Managed workspace "${workspace.name}"`,
17
+ `Root: "${workspace.path}"`,
18
+ ]
19
+ if (links.length === 0) {
20
+ lines.push('Linked folders: none.')
21
+ } else {
22
+ lines.push(`Linked folders (each is a directory link at "<root>/${linksDirectoryName}/<alias>"):`)
23
+ for (const link of links) {
24
+ lines.push(`- "${link.alias}" -> "${link.target}"`)
25
+ }
26
+ }
27
+ lines.push(
28
+ 'Path rules: relative paths resolve from Root. Work inside a linked folder through the path '
29
+ + `"${linksDirectoryName}/<alias>/..." (or its absolute target path).`,
30
+ )
31
+ if (links.length > 0) {
32
+ lines.push(
33
+ 'Searching: glob and grep read through an explicit path, so search a linked folder with '
34
+ + `path "${linksDirectoryName}/<alias>"; a search rooted at Root may not descend into the links.`,
35
+ )
36
+ lines.push(
37
+ 'Sandbox: linked targets lie outside the session workspace root, so workspace-write denies writes there. '
38
+ + 'Switch the session to danger-full-access when a task must modify linked folders.',
39
+ )
40
+ }
41
+ return lines.join('\n')
42
+ }
43
+
44
+ /**
45
+ * Register the system-prompt contribution for managed workspaces.
46
+ * @param {object} ctx - plugin context providing `systemPrompt` and `workingDirectory`.
47
+ * @param {import('./store.js').ManagedWorkspaceStore} store - state owner.
48
+ * @returns {() => void} disposer.
49
+ */
50
+ export function registerManagedWorkspaceContext(ctx, store) {
51
+ const dispose = ctx.systemPrompt.context({
52
+ name: 'workspace:managed',
53
+ order: 300,
54
+ interpolate: false,
55
+ text: (context) => {
56
+ const session = context.agent?.session
57
+ if (session === undefined) return ''
58
+ let directory
59
+ try {
60
+ directory = ctx.workingDirectory.get(session)
61
+ } catch {
62
+ directory = session.header.cwd
63
+ }
64
+ const found = store.findWorkspaceForPath(directory)
65
+ if (found === null) return ''
66
+ const linksDirectoryName = store.manifest?.linksDirectoryName ?? '_roots'
67
+ return renderContext(
68
+ {
69
+ name: found.workspace.name,
70
+ path: found.workspace.path,
71
+ links: found.workspace.links.map((link) => ({ alias: link.alias, target: link.target })),
72
+ },
73
+ linksDirectoryName,
74
+ )
75
+ },
76
+ })
77
+ return typeof dispose === 'function' ? dispose : () => {}
78
+ }
@@ -0,0 +1,10 @@
1
+ # dsh-managed-workspaces bundle patch.
2
+ #
3
+ # One plugin row: the host half (exports ".") owns the workspace manifest, the
4
+ # junction links, the `/dsh-managed-workspaces/api/*` routes, the
5
+ # `managed_workspace` tool, and the prompt context; the `dsh.client`
6
+ # declaration in package.json makes the browser half (exports "./client") load
7
+ # in the Web GUI.
8
+ - insert:
9
+ - id: dsh-managed-workspaces
10
+ name: 'dsh-managed-workspaces'
package/icon.svg ADDED
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Managed workspaces">
2
+ <defs>
3
+ <linearGradient id="mwsFolder" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#4c8dff"/>
5
+ <stop offset="1" stop-color="#2563d9"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="64" height="64" rx="14" fill="#eef3ff"/>
9
+ <path d="M12 22a4 4 0 0 1 4-4h11l4 4h13a4 4 0 0 1 4 4v18a4 4 0 0 1-4 4H16a4 4 0 0 1-4-4z" fill="url(#mwsFolder)"/>
10
+ <path d="M12 28h40v14a4 4 0 0 1-4 4H16a4 4 0 0 1-4-4z" fill="#6ea2ff" opacity="0.55"/>
11
+ <circle cx="46" cy="42" r="11" fill="#1f4fb8"/>
12
+ <path d="M46 36.5v11M40.5 42h11" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/>
13
+ <path d="M18 49h19" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity="0.85"/>
14
+ </svg>
package/index.js ADDED
@@ -0,0 +1,50 @@
1
+ // dsh-managed-workspaces: Host half.
2
+ //
3
+ // Owns the workspace manifest, the workspace folders and their linked folders,
4
+ // the browser JSON routes, the `managed_workspace` tool, and the prompt context.
5
+ // The Web client half lives in client.js.
6
+
7
+ import { ManagedWorkspaceStore } from './store.js'
8
+ import { registerRoutes } from './routes.js'
9
+ import { createManagedWorkspaceTool } from './tool.js'
10
+ import { registerManagedWorkspaceContext } from './context.js'
11
+
12
+ export const name = 'dsh-managed-workspaces'
13
+
14
+ /** Mount every capability the host provides, each disposed with this plugin fiber. */
15
+ export function apply(ctx) {
16
+ const warn = (message) => {
17
+ try {
18
+ ctx.logger?.warn?.(String(message))
19
+ } catch {
20
+ // Logging must never break the plugin.
21
+ }
22
+ }
23
+
24
+ const store = new ManagedWorkspaceStore({ logger: warn })
25
+ ctx.effect(() => () => store.dispose(), 'dsh-managed-workspaces: store')
26
+ store.load().catch((error) => warn(`failed to load the managed workspace manifest: ${error?.message ?? error}`))
27
+
28
+ ctx.inject(['webServer'], (scope) => {
29
+ scope.effect(() => registerRoutes(scope, store), 'dsh-managed-workspaces: routes')
30
+ })
31
+
32
+ ctx.inject(['workspaceRegistry'], (scope) => {
33
+ store.setRegistry(scope.workspaceRegistry)
34
+ scope.effect(() => () => store.setRegistry(null), 'dsh-managed-workspaces: registry')
35
+ })
36
+
37
+ ctx.inject(['tools'], (scope) => {
38
+ scope.effect(
39
+ () => scope.tools.register(createManagedWorkspaceTool(scope, store)),
40
+ 'dsh-managed-workspaces: tool',
41
+ )
42
+ })
43
+
44
+ ctx.inject(['workingDirectory', 'systemPrompt'], (scope) => {
45
+ scope.effect(
46
+ () => registerManagedWorkspaceContext(scope, store),
47
+ 'dsh-managed-workspaces: context',
48
+ )
49
+ })
50
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Managed workspaces",
4
+ "description": "Creates workspace folders under a base directory and links several folders into one workspace."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "托管工作区",
4
+ "description": "在指定根目录下自动创建工作区文件夹,并把多个文件夹关联到同一个工作区。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "dsh-managed-workspaces",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "DSH-owned workspaces created under a configurable base directory, with multiple linked folders per workspace.",
6
+ "keywords": [
7
+ "deepseek-harness",
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "dsh-bundle",
11
+ "workspace",
12
+ "multi-root"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "CoraxEldridge",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/CoraxEldridge/dsh-managed-workspaces.git"
19
+ },
20
+ "homepage": "https://github.com/CoraxEldridge/dsh-managed-workspaces#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/CoraxEldridge/dsh-managed-workspaces/issues"
23
+ },
24
+ "exports": {
25
+ ".": "./index.js",
26
+ "./client": "./client.js",
27
+ "./package.json": "./package.json",
28
+ "./locale/*.json": "./locale/*.json",
29
+ "./icon": "./icon.svg"
30
+ },
31
+ "files": [
32
+ "index.js",
33
+ "store.js",
34
+ "routes.js",
35
+ "tool.js",
36
+ "context.js",
37
+ "client.js",
38
+ "cordis.patch.yml",
39
+ "locale/*.json",
40
+ "icon.svg",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ },
48
+ "client": {
49
+ "platform": "web",
50
+ "immediately": true
51
+ }
52
+ }
53
+ }
package/routes.js ADDED
@@ -0,0 +1,118 @@
1
+ // dsh-managed-workspaces: HTTP routes for the browser half.
2
+ //
3
+ // The host plugin owns the operations; these routes are a thin JSON transport
4
+ // for the Web GUI. They are registered on the harness web server and removed
5
+ // with the plugin fiber.
6
+
7
+ import { ManagedWorkspaceError, errorMessage } from './store.js'
8
+
9
+ const API_PREFIX = '/dsh-managed-workspaces/api/'
10
+ const GUARD_HEADER = 'x-dsh-managed-workspaces'
11
+ const MAX_BODY_BYTES = 64 * 1024
12
+
13
+ function errorPayload(error) {
14
+ if (error instanceof ManagedWorkspaceError) {
15
+ return {
16
+ code: error.code,
17
+ message: error.message,
18
+ ...(error.details === undefined ? {} : { details: error.details }),
19
+ }
20
+ }
21
+ return { code: 'internal', message: errorMessage(error) }
22
+ }
23
+
24
+ function sendJson(response, status, payload) {
25
+ if (response.writableEnded || response.destroyed) return
26
+ response.writeHead(status, {
27
+ 'content-type': 'application/json; charset=utf-8',
28
+ 'cache-control': 'no-store',
29
+ })
30
+ response.end(JSON.stringify(payload))
31
+ }
32
+
33
+ async function readBody(request) {
34
+ const chunks = []
35
+ let total = 0
36
+ for await (const chunk of request) {
37
+ total += chunk.length
38
+ if (total > MAX_BODY_BYTES) {
39
+ throw new ManagedWorkspaceError('body-too-large', `request body exceeds ${MAX_BODY_BYTES} bytes`)
40
+ }
41
+ chunks.push(chunk)
42
+ }
43
+ return Buffer.concat(chunks).toString('utf8')
44
+ }
45
+
46
+ function routeHandler(operation) {
47
+ return async (request, response) => {
48
+ try {
49
+ if (request.method !== 'POST') {
50
+ sendJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'POST required' } })
51
+ return
52
+ }
53
+ if (request.headers[GUARD_HEADER] !== '1') {
54
+ sendJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'missing request marker header' } })
55
+ return
56
+ }
57
+ const body = await readBody(request)
58
+ let args = {}
59
+ if (body.trim().length > 0) {
60
+ try {
61
+ args = JSON.parse(body)
62
+ } catch {
63
+ sendJson(response, 400, { ok: false, error: { code: 'bad-json', message: 'request body is not JSON' } })
64
+ return
65
+ }
66
+ }
67
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) {
68
+ sendJson(response, 400, { ok: false, error: { code: 'bad-json', message: 'request body must be a JSON object' } })
69
+ return
70
+ }
71
+ const result = await operation(args, request)
72
+ sendJson(response, 200, { ok: true, ...(result ?? {}) })
73
+ } catch (error) {
74
+ const status = error instanceof ManagedWorkspaceError && error.code === 'body-too-large' ? 413 : 200
75
+ sendJson(response, status, { ok: false, error: errorPayload(error) })
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Register every plugin route on the hosting web server.
82
+ * @param {object} ctx - plugin context providing `webServer`.
83
+ * @param {import('./store.js').ManagedWorkspaceStore} store - state owner.
84
+ * @returns {() => void} disposer removing every route.
85
+ */
86
+ export function registerRoutes(ctx, store) {
87
+ const webServer = ctx.webServer
88
+ const disposers = []
89
+ const route = (name, operation) => {
90
+ disposers.push(
91
+ webServer.register({
92
+ kind: 'exact',
93
+ path: API_PREFIX + name,
94
+ handler: routeHandler(operation),
95
+ }),
96
+ )
97
+ }
98
+
99
+ route('state', async () => store.state())
100
+ route('config', async (args) => store.setBaseDirectory(args.baseDirectory))
101
+ route('create', async (args) => store.create(args.name))
102
+ route('adopt', async (args) => (args.all === true ? store.adoptAll() : store.adopt(args.folder)))
103
+ route('attach', async (args) => store.attach(args.id, args.folder, args.alias))
104
+ route('detach', async (args) => store.detach(args.id, args.alias))
105
+ route('register', async (args) => store.register(args.id))
106
+ route('forget', async (args) => store.forget(args.id))
107
+ route('repair', async (args) => store.repair(args.id))
108
+
109
+ return () => {
110
+ for (const dispose of disposers) {
111
+ try {
112
+ dispose()
113
+ } catch {
114
+ // A route already removed by a reload must not block the rest.
115
+ }
116
+ }
117
+ }
118
+ }