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/LICENSE +21 -0
- package/README.en.md +153 -0
- package/README.md +152 -2
- package/cordis.patch.yml +4 -0
- package/docs/badges/dsh.svg +5 -0
- package/docs/badges/license.svg +5 -0
- package/docs/badges/node.svg +5 -0
- package/docs/images/activity.png +0 -0
- package/docs/images/groups.png +0 -0
- package/docs/images/projects.png +0 -0
- package/lib/client.js +2596 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +194 -0
- package/package.json +69 -4
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|