dsh-task-groups 0.0.0-stage → 0.1.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/lib/index.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** Cordis plugin name used by the profile bundle. */
2
+ export declare const name: 'task-groups'
3
+
4
+ /** Services the Host half needs. */
5
+ export declare const inject: readonly ['webServer']
6
+
7
+ /** Absolute path of the persisted state file (`$DSH_HOME/task-groups/state.json`). */
8
+ export declare function stateFile(): string
9
+
10
+ /** Register the `/dsh-task-groups` state route on a Cordis Host context. */
11
+ export declare function apply(ctx: {
12
+ effect: (fn: () => unknown, label?: string) => unknown
13
+ webServer: {
14
+ register: (options: {
15
+ kind: 'prefix'
16
+ path: string
17
+ handler: (req: unknown, res: unknown) => unknown
18
+ }) => unknown
19
+ }
20
+ }): void
package/lib/index.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * dsh-task-groups — host half.
3
+ *
4
+ * Owns one small persisted state file (`$DSH_HOME/task-groups/state.json`) and
5
+ * exposes it to the browser half over two same-origin routes:
6
+ *
7
+ * GET /dsh-task-groups/state -> { ok, state }
8
+ * PUT /dsh-task-groups/state <- { state } -> { ok, savedAt }
9
+ * GET /dsh-task-groups/health -> { ok, path }
10
+ *
11
+ * The file is the source of truth: group definitions and session→group
12
+ * assignments must survive a page reload, a port change, and a Host restart,
13
+ * so they cannot live in the browser's localStorage.
14
+ */
15
+
16
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
17
+ import { homedir } from 'node:os'
18
+ import { dirname, join } from 'node:path'
19
+
20
+ /** Stable Cordis plugin name. */
21
+ export const name = 'task-groups'
22
+
23
+ /** The Web carrier is the only service this plugin needs. */
24
+ export const inject = ['webServer']
25
+
26
+ const ROUTE_PREFIX = '/dsh-task-groups'
27
+ const BODY_LIMIT = 512 * 1024
28
+ const MAX_GROUPS = 200
29
+ const MAX_NAME = 80
30
+ const MAX_ASSIGNMENTS = 5000
31
+
32
+ /** Absolute path of the persisted state file. */
33
+ export function stateFile() {
34
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
35
+ return join(home, 'task-groups', 'state.json')
36
+ }
37
+
38
+ function badRequest(message) {
39
+ const error = new Error(message)
40
+ error.status = 400
41
+ return error
42
+ }
43
+
44
+ /** Keep only the fields the browser owns; drop anything else. */
45
+ function normalizeState(raw) {
46
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw badRequest('state must be an object')
47
+ if (raw.version !== 1) throw badRequest('unsupported state version')
48
+
49
+ const groupsIn = Array.isArray(raw.groups) ? raw.groups : []
50
+ if (groupsIn.length > MAX_GROUPS) throw badRequest('too many groups')
51
+ const seen = new Set()
52
+ const groups = []
53
+ for (const group of groupsIn) {
54
+ if (group === null || typeof group !== 'object' || Array.isArray(group)) throw badRequest('group must be an object')
55
+ const id = typeof group.id === 'string' ? group.id.trim() : ''
56
+ if (id === '' || id.length > 64 || seen.has(id)) throw badRequest('invalid group id')
57
+ seen.add(id)
58
+ const rawName = typeof group.name === 'string' ? group.name.trim() : ''
59
+ if (rawName === '') throw badRequest('group name is required')
60
+ groups.push({
61
+ id,
62
+ name: rawName.slice(0, MAX_NAME),
63
+ color: typeof group.color === 'string' ? group.color.slice(0, 24) : 'gray',
64
+ collapsed: group.collapsed === true,
65
+ order: Number.isFinite(group.order) ? Number(group.order) : groups.length,
66
+ })
67
+ }
68
+
69
+ const assignmentsIn = raw.assignments && typeof raw.assignments === 'object' && !Array.isArray(raw.assignments) ? raw.assignments : {}
70
+ const entries = Object.entries(assignmentsIn)
71
+ if (entries.length > MAX_ASSIGNMENTS) throw badRequest('too many assignments')
72
+ const assignments = {}
73
+ for (const [sessionId, groupId] of entries) {
74
+ if (typeof sessionId !== 'string' || sessionId === '' || sessionId.length > 128) continue
75
+ if (typeof groupId !== 'string' || !seen.has(groupId)) continue
76
+ assignments[sessionId] = groupId
77
+ }
78
+
79
+ const prefsIn = raw.prefs && typeof raw.prefs === 'object' && !Array.isArray(raw.prefs) ? raw.prefs : {}
80
+ const prefs = {
81
+ view: prefsIn.view === 'projects' ? 'projects' : 'groups',
82
+ activity: prefsIn.activity === true,
83
+ }
84
+
85
+ const orderIn = Array.isArray(raw.sessionOrder) ? raw.sessionOrder : []
86
+ if (orderIn.length > MAX_ASSIGNMENTS) throw badRequest('too many ordered sessions')
87
+ const sessionOrder = []
88
+ for (const id of orderIn) {
89
+ if (typeof id !== 'string' || id === '' || id.length > 128) continue
90
+ sessionOrder.push(id)
91
+ }
92
+
93
+ return { version: 1, groups, assignments, sessionOrder, prefs }
94
+ }
95
+
96
+ async function readState() {
97
+ try {
98
+ const text = await readFile(stateFile(), 'utf8')
99
+ return normalizeState(JSON.parse(text))
100
+ } catch (error) {
101
+ if (error.code === 'ENOENT') return null
102
+ if (error.status === 400) {
103
+ // A hand-edited/corrupt file must not brick the panel: fall back to empty.
104
+ console.warn('[dsh-task-groups] ignoring unreadable state file:', error.message)
105
+ return null
106
+ }
107
+ throw error
108
+ }
109
+ }
110
+
111
+ async function writeState(raw) {
112
+ const state = normalizeState(raw)
113
+ const file = stateFile()
114
+ await mkdir(dirname(file), { recursive: true })
115
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}`
116
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
117
+ await rename(temp, file)
118
+ return state
119
+ }
120
+
121
+ function sendJson(res, status, value) {
122
+ if (res.destroyed || res.writableEnded) return
123
+ const body = JSON.stringify(value)
124
+ res.writeHead(status, {
125
+ 'content-type': 'application/json; charset=utf-8',
126
+ 'cache-control': 'no-store',
127
+ 'content-length': Buffer.byteLength(body),
128
+ })
129
+ res.end(body)
130
+ }
131
+
132
+ async function readJson(req) {
133
+ const chunks = []
134
+ let size = 0
135
+ for await (const chunk of req) {
136
+ size += chunk.length
137
+ if (size > BODY_LIMIT) throw badRequest('request body too large')
138
+ chunks.push(chunk)
139
+ }
140
+ if (size === 0) throw badRequest('request body is required')
141
+ try {
142
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
143
+ } catch {
144
+ throw badRequest('request body must be JSON')
145
+ }
146
+ }
147
+
148
+ async function handle(req, res) {
149
+ const url = new URL(req.url ?? '/', 'http://localhost')
150
+ const rest = url.pathname.slice(ROUTE_PREFIX.length).replace(/^\/+|\/+$/gu, '')
151
+
152
+ if (rest === 'health' && req.method === 'GET') {
153
+ sendJson(res, 200, { ok: true, path: stateFile() })
154
+ return
155
+ }
156
+ if (rest !== 'state') {
157
+ sendJson(res, 404, { ok: false, error: 'not found' })
158
+ return
159
+ }
160
+ if (req.method === 'GET') {
161
+ // A fresh profile has no file yet: hand back an empty, fully-shaped state.
162
+ sendJson(res, 200, { ok: true, state: (await readState()) ?? normalizeState({ version: 1 }) })
163
+ return
164
+ }
165
+ if (req.method === 'PUT' || req.method === 'POST') {
166
+ const body = await readJson(req)
167
+ const state = await writeState(body?.state)
168
+ sendJson(res, 200, { ok: true, savedAt: Date.now(), groups: state.groups.length })
169
+ return
170
+ }
171
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
172
+ }
173
+
174
+ /**
175
+ * Register the state route for the lifetime of this plugin fiber.
176
+ * @param ctx - Host context carrying `webServer`.
177
+ */
178
+ export function apply(ctx) {
179
+ ctx.effect(
180
+ () =>
181
+ ctx.webServer.register({
182
+ kind: 'prefix',
183
+ path: ROUTE_PREFIX,
184
+ handler: async (req, res) => {
185
+ try {
186
+ await handle(req, res)
187
+ } catch (error) {
188
+ sendJson(res, error.status ?? 500, { ok: false, error: error.message ?? String(error) })
189
+ }
190
+ },
191
+ }),
192
+ 'dsh-task-groups: state route',
193
+ )
194
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "dsh-task-groups",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Task groups and an activity bell for the DSH sidebar: organize sessions by your own groups and follow running/unread conversations.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./client": {
14
+ "default": "./lib/client.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "lib",
20
+ "docs",
21
+ "cordis.patch.yml",
22
+ "README.md",
23
+ "README.en.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "dsh",
28
+ "dsh-plugin",
29
+ "deepseek-harness",
30
+ "sidebar",
31
+ "task-groups",
32
+ "activity"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/spike237/dsh-task-groups.git"
37
+ },
38
+ "homepage": "https://github.com/spike237/dsh-task-groups#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/spike237/dsh-task-groups/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ },
49
+ "client": {
50
+ "platform": "web",
51
+ "inject": [
52
+ "@deepseek-ai/dsh-client-ui-sidebar",
53
+ "@deepseek-ai/dsh-client-ui-workspace",
54
+ "@deepseek-ai/dsh-client-ui-session",
55
+ "@deepseek-ai/dsh-client-ui-slots",
56
+ "@deepseek-ai/dsh-client-ui-primitives",
57
+ "@deepseek-ai/dsh-client-locale"
58
+ ]
59
+ }
60
+ },
61
+ "engines": {
62
+ "node": ">=20"
63
+ },
64
+ "scripts": {
65
+ "test": "node test/render.test.mjs",
66
+ "smoke": "node tools/smoke-host.mjs",
67
+ "install-profile": "node tools/enable-profile.mjs",
68
+ "uninstall-profile": "node tools/enable-profile.mjs --remove"
69
+ },
70
+ "license": "MIT"
71
+ }