@restregular/dsh-notepad 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.
@@ -0,0 +1,9 @@
1
+ # dsh-notepad bundle patch: inserts the Host plugin (the loopback note store
2
+ # route under /dsh-notepad/api) and the Client plugin (the floating notepad
3
+ # registered into the shell.overlay slot).
4
+ #
5
+ # `name` must be byte-for-byte the package.json name: the client module graph
6
+ # locates the package by the Loader row name.
7
+ - insert:
8
+ - id: dsh-notepad
9
+ name: '@restregular/dsh-notepad'
package/icon.svg ADDED
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
2
+ <defs>
3
+ <linearGradient id="bg" x1="32" y1="24" x2="224" y2="232" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#4D6BFE"/>
5
+ <stop offset="1" stop-color="#2E3FCB"/>
6
+ </linearGradient>
7
+ <linearGradient id="paper" x1="72" y1="64" x2="176" y2="208" gradientUnits="userSpaceOnUse">
8
+ <stop stop-color="#FFFFFF"/>
9
+ <stop offset="1" stop-color="#E8ECFF"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg)"/>
13
+ <path d="M78 66h100a18 18 0 0 1 18 18v102a18 18 0 0 1-18 18H78a18 18 0 0 1-18-18V84a18 18 0 0 1 18-18Z" fill="url(#paper)"/>
14
+ <path d="M88 96h56" stroke="#9AA9FF" stroke-width="12" stroke-linecap="round"/>
15
+ <path d="M88 128h72" stroke="#9AA9FF" stroke-width="12" stroke-linecap="round"/>
16
+ <path d="M88 160h40" stroke="#9AA9FF" stroke-width="12" stroke-linecap="round"/>
17
+ <path d="m178.5 96.5 17 17a10 10 0 0 1 0 14.1l-44.3 44.3-22.9 6.3a5 5 0 0 1-6.2-6.2l6.3-22.9 44.3-44.3a10 10 0 0 1 14.1 0Z" fill="#FFB020" stroke="#2E3FCB" stroke-width="6" stroke-linejoin="round"/>
18
+ <path d="m166.5 108.5 17 17" stroke="#2E3FCB" stroke-width="6" stroke-linecap="round"/>
19
+ </svg>
package/index.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * dsh-notepad — Host half.
3
+ *
4
+ * Stores one plain-text note per workspace in `<workspace>/.dsh/notepad.json`
5
+ * and exposes it to the same-origin browser overlay through the loopback route
6
+ * `/dsh-notepad/api/note`.
7
+ *
8
+ * Security:
9
+ * - only loopback requests with a same-origin (or absent) Origin are served;
10
+ * - the resolved root must be a registered workspace or the exact cwd of the
11
+ * requesting session;
12
+ * - the file path itself is fixed (`.dsh/notepad.json`), so the route cannot
13
+ * be used to read or write arbitrary workspace files.
14
+ *
15
+ * @module dsh-notepad
16
+ */
17
+
18
+ import path from 'node:path';
19
+ import {
20
+ DEFAULT_MAX_BYTES,
21
+ NOTE_VERSION,
22
+ NoteStoreError,
23
+ readNote,
24
+ writeNote,
25
+ } from './lib/note-store.js';
26
+
27
+ /** Loopback route prefix. */
28
+ const ROUTE = '/dsh-notepad/api';
29
+
30
+ /** Plugin version mirrored in package.json. */
31
+ const VERSION = '0.1.0';
32
+
33
+ /** Request body cap. */
34
+ const BODY_LIMIT = 1024 * 1024;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // HTTP helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** @param {import('node:http').ServerResponse} res */
41
+ function sendJson(res, payload, status = 200) {
42
+ const body = JSON.stringify(payload);
43
+ res.writeHead(status, {
44
+ 'Content-Type': 'application/json; charset=utf-8',
45
+ 'Cache-Control': 'no-store',
46
+ });
47
+ res.end(body);
48
+ }
49
+
50
+ /**
51
+ * Read and parse a JSON object body.
52
+ * @param {import('node:http').IncomingMessage} req
53
+ * @param {number} limit
54
+ */
55
+ function readJsonBody(req, limit = BODY_LIMIT) {
56
+ return new Promise((resolve, reject) => {
57
+ const chunks = [];
58
+ let total = 0;
59
+ req.on('data', (chunk) => {
60
+ total += chunk.length;
61
+ if (total > limit) {
62
+ reject(new NoteStoreError('too-large', 'Request body is too large.'));
63
+ req.destroy();
64
+ return;
65
+ }
66
+ chunks.push(chunk);
67
+ });
68
+ req.on('end', () => {
69
+ try {
70
+ const text = Buffer.concat(chunks).toString('utf8');
71
+ const value = text === '' ? {} : JSON.parse(text);
72
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
73
+ throw new Error('not an object');
74
+ }
75
+ resolve(value);
76
+ } catch {
77
+ reject(new NoteStoreError('bad-input', 'Request body is not a JSON object.'));
78
+ }
79
+ });
80
+ req.on('error', reject);
81
+ });
82
+ }
83
+
84
+ /** Only the same machine may drive the route. */
85
+ function isLoopback(req) {
86
+ const address = req.socket?.remoteAddress ?? '';
87
+ return (
88
+ address === '127.0.0.1' ||
89
+ address === '::1' ||
90
+ address.startsWith('::ffff:127.') ||
91
+ address === ''
92
+ );
93
+ }
94
+
95
+ /** Reject cross-origin form posts; the page is served from the same origin. */
96
+ function sameOrigin(req) {
97
+ const origin = req.headers.origin;
98
+ if (typeof origin !== 'string' || origin === '') return true;
99
+ try {
100
+ return new URL(origin).host === req.headers.host;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Workspace validation
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /** Case-insensitive path equality (Windows) with lexical normalization. */
111
+ function samePath(a, b) {
112
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
113
+ const norm = (value) => {
114
+ let out = value.replace(/[\\/]+/gu, '/').replace(/\/+$/u, '');
115
+ if (process.platform === 'win32') out = out.toLowerCase();
116
+ return out;
117
+ };
118
+ return norm(a) === norm(b);
119
+ }
120
+
121
+ /**
122
+ * Resolve the workspace root a request is allowed to touch.
123
+ * @param {import('@deepseek-ai/cordis').Context} ctx
124
+ * @param {Record<string, unknown>} args
125
+ * @returns {Promise<string>}
126
+ */
127
+ async function resolveRequestRoot(ctx, args) {
128
+ const requested = typeof args.root === 'string' ? args.root : '';
129
+ if (requested === '') {
130
+ throw new NoteStoreError('bad-root', 'root is required.');
131
+ }
132
+ const root = path.resolve(requested);
133
+ if (await isKnownRoot(ctx, root, typeof args.sessionId === 'string' ? args.sessionId : '')) {
134
+ return root;
135
+ }
136
+ throw new NoteStoreError('unknown-workspace', 'This directory is not a registered workspace.');
137
+ }
138
+
139
+ /**
140
+ * A root is allowed when it is a registered workspace or the exact working
141
+ * directory of the requesting session.
142
+ * @param {import('@deepseek-ai/cordis').Context} ctx
143
+ * @param {string} root
144
+ * @param {string} sessionId
145
+ */
146
+ async function isKnownRoot(ctx, root, sessionId) {
147
+ const registry = ctx.get('workspaceRegistry');
148
+ if (registry !== undefined && typeof registry.list === 'function') {
149
+ const known = registry.list();
150
+ if (Array.isArray(known) && known.some((workspace) => samePath(workspace?.path, root))) return true;
151
+ }
152
+ if (sessionId !== '') {
153
+ const agent = ctx.get('agents')?.get?.(sessionId);
154
+ const cwd = agent?.session?.header?.cwd;
155
+ if (samePath(cwd, root)) return true;
156
+ }
157
+ return false;
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Route
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Handle one API request. Exported for route tests.
166
+ * @param {import('@deepseek-ai/cordis').Context} ctx
167
+ * @param {import('node:http').IncomingMessage} req
168
+ * @param {import('node:http').ServerResponse} res
169
+ */
170
+ export async function handleRequest(ctx, req, res) {
171
+ try {
172
+ await dispatchRequest(ctx, req, res);
173
+ } catch (error) {
174
+ if (error instanceof NoteStoreError) {
175
+ const status = error.code === 'unknown-workspace' || error.code === 'bad-root' ? 403 : 400;
176
+ sendJson(res, { ok: false, code: error.code, error: error.message }, status);
177
+ return;
178
+ }
179
+ ctx.logger?.warn?.(`dsh-notepad: ${error?.stack ?? error}`);
180
+ sendJson(res, { ok: false, code: 'internal', error: String(error?.message ?? error) }, 500);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Dispatch one already-authenticated API request.
186
+ * @param {import('@deepseek-ai/cordis').Context} ctx
187
+ * @param {import('node:http').IncomingMessage} req
188
+ * @param {import('node:http').ServerResponse} res
189
+ */
190
+ async function dispatchRequest(ctx, req, res) {
191
+ if (!isLoopback(req) || !sameOrigin(req)) {
192
+ sendJson(res, { ok: false, code: 'forbidden', error: 'Forbidden.' }, 403);
193
+ return;
194
+ }
195
+ const url = new URL(req.url ?? '/', 'http://localhost');
196
+ if (!url.pathname.startsWith(ROUTE)) {
197
+ sendJson(res, { ok: false, code: 'not-found', error: 'Not found.' }, 404);
198
+ return;
199
+ }
200
+ const action = url.pathname.slice(ROUTE.length).replace(/^\/+|\/+$/gu, '');
201
+ if (action === 'ping') {
202
+ sendJson(res, { ok: true, plugin: 'dsh-notepad', version: VERSION, noteVersion: NOTE_VERSION });
203
+ return;
204
+ }
205
+ if (action !== 'note') {
206
+ sendJson(res, { ok: false, code: 'not-found', error: `Unknown endpoint "${action}".` }, 404);
207
+ return;
208
+ }
209
+ if (req.method === 'GET') {
210
+ const root = await resolveRequestRoot(ctx, {
211
+ root: url.searchParams.get('root') ?? '',
212
+ sessionId: url.searchParams.get('sessionId') ?? '',
213
+ });
214
+ const note = await readNote(root);
215
+ sendJson(res, { ok: true, root, ...note });
216
+ return;
217
+ }
218
+ if (req.method === 'PUT') {
219
+ const body = await readJsonBody(req);
220
+ const root = await resolveRequestRoot(ctx, body);
221
+ const note = await writeNote(root, body.text, { maxBytes: DEFAULT_MAX_BYTES });
222
+ sendJson(res, { ok: true, root, ...note });
223
+ return;
224
+ }
225
+ sendJson(res, { ok: false, code: 'method', error: 'Use GET or PUT.' }, 405);
226
+ }
227
+
228
+ /**
229
+ * Mount the loopback route.
230
+ * @param {import('@deepseek-ai/cordis').Context} ctx
231
+ */
232
+ function mountRoutes(ctx) {
233
+ const webServer = ctx.webServer ?? ctx.get('webServer');
234
+ if (webServer === undefined) return;
235
+ ctx.effect(
236
+ () =>
237
+ webServer.register({
238
+ kind: 'prefix',
239
+ path: ROUTE,
240
+ async handler(req, res) {
241
+ try {
242
+ await handleRequest(ctx, req, res);
243
+ } catch (error) {
244
+ ctx.logger?.warn?.(`dsh-notepad: ${error?.stack ?? error}`);
245
+ }
246
+ },
247
+ }),
248
+ 'dsh-notepad: loopback note route',
249
+ );
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Plugin body
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /** Loader identity of the Host half. */
257
+ export const name = 'dsh-notepad';
258
+
259
+ /**
260
+ * Mount the note route. Without `webServer` the overlay degrades to an
261
+ * in-memory note for the lifetime of the page.
262
+ * @param {import('@deepseek-ai/cordis').Context} ctx
263
+ */
264
+ export function apply(ctx) {
265
+ ctx.inject(['webServer'], (scoped) => mountRoutes(scoped));
266
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * dsh-notepad — workspace note storage.
3
+ *
4
+ * One plain-text note per workspace, kept at `<workspace>/.dsh/notepad.json`.
5
+ * The JSON envelope lets the file carry its own `updatedAt` while still
6
+ * degrading gracefully: if the file is not JSON (a user or another tool
7
+ * edited it by hand), the raw bytes are returned as the note text instead of
8
+ * losing the content.
9
+ *
10
+ * @module dsh-notepad/lib/note-store
11
+ */
12
+
13
+ import { randomBytes } from 'node:crypto';
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ /** Directory under the workspace root. */
18
+ export const NOTE_DIR = '.dsh';
19
+
20
+ /** File name inside {@link NOTE_DIR}. */
21
+ export const NOTE_FILE = 'notepad.json';
22
+
23
+ /** Default maximum note size (UTF-8 bytes). */
24
+ export const DEFAULT_MAX_BYTES = 512 * 1024;
25
+
26
+ /** Storage envelope version. */
27
+ export const NOTE_VERSION = 1;
28
+
29
+ /** A note-store failure with a stable machine-readable code. */
30
+ export class NoteStoreError extends Error {
31
+ /**
32
+ * @param {string} code
33
+ * @param {string} message
34
+ */
35
+ constructor(code, message) {
36
+ super(message);
37
+ this.name = 'NoteStoreError';
38
+ this.code = code;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Absolute path of the note file.
44
+ * @param {string} root workspace root
45
+ */
46
+ export function notePath(root) {
47
+ return path.join(root, NOTE_DIR, NOTE_FILE);
48
+ }
49
+
50
+ /**
51
+ * Decode a stored note file.
52
+ * @param {string} raw file contents
53
+ * @param {number | undefined} mtimeMs
54
+ */
55
+ export function parseNote(raw, mtimeMs) {
56
+ let text = raw;
57
+ let updatedAt;
58
+ try {
59
+ const parsed = JSON.parse(raw);
60
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.text === 'string') {
61
+ text = parsed.text;
62
+ if (typeof parsed.updatedAt === 'string') updatedAt = parsed.updatedAt;
63
+ }
64
+ } catch {
65
+ /* Not our envelope: keep the raw text. */
66
+ }
67
+ return {
68
+ text,
69
+ updatedAt: updatedAt ?? (typeof mtimeMs === 'number' ? new Date(mtimeMs).toISOString() : null),
70
+ mtimeMs: typeof mtimeMs === 'number' ? mtimeMs : null,
71
+ exists: true,
72
+ size: Buffer.byteLength(raw, 'utf8'),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Read one workspace note. A missing file is an empty note, not an error.
78
+ * @param {string} root workspace root
79
+ * @returns {Promise<{text: string, updatedAt: string | null, mtimeMs: number | null, exists: boolean, size: number}>}
80
+ */
81
+ export async function readNote(root) {
82
+ const file = notePath(root);
83
+ let raw;
84
+ try {
85
+ raw = await fs.readFile(file, 'utf8');
86
+ } catch (error) {
87
+ if (error?.code === 'ENOENT') {
88
+ return { text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
89
+ }
90
+ throw error;
91
+ }
92
+ let mtimeMs;
93
+ try {
94
+ const stat = await fs.stat(file);
95
+ mtimeMs = stat.mtimeMs;
96
+ } catch {
97
+ mtimeMs = undefined;
98
+ }
99
+ return parseNote(raw, mtimeMs);
100
+ }
101
+
102
+ /**
103
+ * Replace one workspace note atomically.
104
+ * @param {string} root workspace root
105
+ * @param {string} text note body
106
+ * @param {{ maxBytes?: number }} [options]
107
+ */
108
+ export async function writeNote(root, text, options = {}) {
109
+ if (typeof text !== 'string') {
110
+ throw new NoteStoreError('bad-input', 'Note text must be a string.');
111
+ }
112
+ const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
113
+ const size = Buffer.byteLength(text, 'utf8');
114
+ if (size > maxBytes) {
115
+ throw new NoteStoreError('too-large', `Note is too large (${size} bytes; limit ${maxBytes} bytes).`);
116
+ }
117
+ const dir = path.join(root, NOTE_DIR);
118
+ const file = path.join(dir, NOTE_FILE);
119
+ await fs.mkdir(dir, { recursive: true });
120
+ const updatedAt = new Date().toISOString();
121
+ const payload = `${JSON.stringify({ version: NOTE_VERSION, text, updatedAt }, null, 2)}\n`;
122
+ const tmp = path.join(dir, `.${NOTE_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
123
+ await fs.writeFile(tmp, payload, 'utf8');
124
+ try {
125
+ await fs.rename(tmp, file);
126
+ } catch (error) {
127
+ await fs.rm(tmp, { force: true }).catch(() => {});
128
+ throw error;
129
+ }
130
+ const stat = await fs.stat(file);
131
+ return {
132
+ text,
133
+ updatedAt,
134
+ mtimeMs: stat.mtimeMs,
135
+ exists: true,
136
+ size: stat.size,
137
+ };
138
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Notepad",
4
+ "description": "A per-workspace floating notepad in the session view: jot down problems and next steps, insert the note into the composer with one click, drag, minimize, and hide."
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,71 @@
1
1
  {
2
2
  "name": "@restregular/dsh-notepad",
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": "Notepad — a per-workspace floating note window in the DeepSeek Harness session view: jot down problems and next steps, insert the note into the composer with one click, drag, minimize, and hide. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的文本浮窗:记录问题与下一步,一键插入输入框,支持拖拽、最小化和快捷键隐藏。",
5
+ "icon": "./icon.svg",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg",
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "client.js",
19
+ "lib/",
20
+ "locale/*.json",
21
+ "cordis.patch.yml",
22
+ "icon.svg",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "dsh": {
27
+ "bundle": {
28
+ "patch": "./cordis.patch.yml"
29
+ },
30
+ "client": {
31
+ "platform": "web",
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-locale",
34
+ "@deepseek-ai/dsh-client-ui-layout",
35
+ "@deepseek-ai/dsh-api-session-controller",
36
+ "@deepseek-ai/dsh-client-ui-session",
37
+ "@deepseek-ai/dsh-client-ui-conversation"
38
+ ]
39
+ }
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/RestRegular/dsh-notepad.git"
44
+ },
45
+ "homepage": "https://github.com/RestRegular/dsh-notepad#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/RestRegular/dsh-notepad/issues"
48
+ },
49
+ "keywords": [
50
+ "dsh-plugin",
51
+ "deepseek-harness",
52
+ "deepseek",
53
+ "notepad",
54
+ "notes",
55
+ "scratchpad",
56
+ "workspace",
57
+ "floating"
58
+ ],
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "registry": "https://registry.npmjs.org/"
62
+ },
63
+ "engines": {
64
+ "node": ">=22.13.0"
65
+ },
66
+ "license": "MIT",
67
+ "author": "RestRegular",
68
+ "scripts": {
69
+ "test": "node test/note-store.test.mjs && node test/route.test.mjs && node test/client-smoke.mjs && node test/package.test.mjs"
70
+ }
71
+ }