@1outres/dsh-plugin-scratchpad 0.0.0-stage → 0.5.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1outres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,131 @@
1
- # Temporary Holding Version
1
+ # @1outres/dsh-plugin-scratchpad
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A place for work that belongs to no repository: one-off scripts, small
4
+ experiments, research, writing. **New scratch** creates a new empty directory,
5
+ registers it as its own workspace, and opens a session there. Archive the
6
+ session when you are done and the directory is moved out of the way.
7
+
8
+ Install just this plugin:
9
+
10
+ ```sh
11
+ dsh plugin add @1outres/dsh-plugin-scratchpad
12
+ ```
13
+
14
+ Or install it with every plugin in the repository through the
15
+ [`@1outres/dsh-plugins`](../dsh-plugins) bundle. Enable one or the other: both
16
+ insert the same `scratchpad` row.
17
+
18
+ ## Starting a scratch
19
+
20
+ Any of these opens a scratch:
21
+
22
+ - **New scratch** at the foot of the sidebar, beside Settings.
23
+ - `/scratch` in the composer of any session.
24
+ - **New scratch** in the New Session row of a blank session in the
25
+ `scratchpad` root workspace (see below).
26
+
27
+ A new scratch is `~/.dsh/scratchpad/YYYY-MM-DD-HHmmss/`, named after the local
28
+ time of the browser and suffixed `-2`, `-3`, ... only on a collision. The
29
+ directory starts empty: no `git init`, no files. The workspace is titled after
30
+ the directory; renaming the workspace or the session never renames the
31
+ directory.
32
+
33
+ When the newest scratch is still unused (nothing was sent in any of its
34
+ sessions and none is archived), the button opens it again instead of creating
35
+ another empty directory.
36
+
37
+ Each scratch is its own workspace, so the session's file policy covers that
38
+ directory only, and the Files panel, terminal and **New Session** in the
39
+ workspace work as anywhere else.
40
+
41
+ ### The root workspace
42
+
43
+ Creating a scratch also registers the root, `~/.dsh/scratchpad`, as a
44
+ workspace. With **View options → Group by → Workspace Tree** in the sidebar's
45
+ Workspaces header, every scratch nests under it, so the scratches read as one
46
+ `scratchpad` project. The grouping choice is saved per browser and defaults to
47
+ flat.
48
+
49
+ **New Session** in the root workspace opens a session in the root itself. That
50
+ session can write to every scratch, which suits work across scratches. Its
51
+ New Session row shows a **New scratch** chip to start in a new scratch
52
+ instead. Set `registerRoot: false` to stop registering the root.
53
+
54
+ ## Archiving a scratch
55
+
56
+ When every session in a scratch is archived, the plugin:
57
+
58
+ 1. Waits until those sessions stop running anything: the turn, subagents,
59
+ background jobs, schedules and terminals. After two minutes it gives up and
60
+ leaves the directory where it is; the next Harness start tries again.
61
+ 2. Checks the sessions are still archived (the archive notice offers Undo).
62
+ 3. Moves the directory to `~/.dsh/scratchpad/.archived/<name>` and removes
63
+ the workspace from the sidebar. The archived sessions stay under
64
+ **Ungrouped**, hidden like any archived session.
65
+
66
+ Unarchiving any of those sessions moves the directory back before the sidebar
67
+ shows the session again, registers the workspace, and attaches its sessions.
68
+ Forking an archived session does the same. A workspace restored this way
69
+ starts at the top of the sidebar.
70
+
71
+ Archiving a session in the root workspace moves nothing. Deleting a scratch
72
+ workspace from the sidebar only removes the registration, as it does for any
73
+ workspace; the plugin leaves that directory alone and does not register it
74
+ again. Nothing is ever deleted: empty `.archived` entries stay until you
75
+ remove them.
76
+
77
+ The Host half catches up at startup with archives and unarchives made while it
78
+ was off.
79
+
80
+ ## Settings
81
+
82
+ Edit them on the plugin's settings page, or in the profile patch under the
83
+ `scratchpad` row. Changing either restarts the Host half.
84
+
85
+ | Field | Default | Meaning |
86
+ | -------------- | ------------------- | ------------------------------------------------------------------ |
87
+ | `root` | `~/.dsh/scratchpad` | Directory that holds the scratches. `~` is the Host user's home. |
88
+ | `registerRoot` | `true` | Register the root as a workspace too, for Workspace Tree grouping. |
89
+
90
+ The root does not follow `$DSH_HOME`; set `root` when you moved the Harness
91
+ home.
92
+
93
+ ## How it works
94
+
95
+ 1. The browser half creates the session with `sessions.create({ cwd })`. The
96
+ Host creates the directory with the session, which fixes the session's
97
+ working directory and file-policy root.
98
+ 2. The Host half sees the new Agent in `agent/created`, registers the root
99
+ and the scratch with `workspaceRegistry.create`, and attaches the session.
100
+ The browser waits for that workspace and opens it like any other.
101
+ 3. The Host half follows the registry's archive set through the storage
102
+ `domain/changed` event, which fires after each durable registry write and
103
+ before any browser hears of it. Archive moves wait in the background;
104
+ unarchive moves run synchronously inside the event.
105
+ 4. Every move is one `rename` inside the root. Files are never copied or
106
+ deleted.
107
+
108
+ ## Limits
109
+
110
+ - **Deleting `~/.dsh` deletes the scratches too.** Set `root` elsewhere to
111
+ keep them apart from Harness data.
112
+ - The archive bookkeeping reads Harness internals: the Workspace registry's
113
+ stored state (`archivedSessionIds`) through `domain/changed`, and the
114
+ `workspace/session-activity` check. A Harness update within
115
+ `>=0.2.1-rc.5 <0.3.0` can still change them; the move then stops happening
116
+ while creation keeps working.
117
+ - Waiting for running work covers what the Harness reports. A persistent
118
+ shell, an MCP server or any process writing to an absolute path in the
119
+ scratch is not tracked, and can recreate files at the old path after the
120
+ move.
121
+ - A restored scratch loses its position in the sidebar and its session order
122
+ is rebuilt newest first. A custom workspace title is reset to the directory
123
+ name.
124
+ - The browser decides name collisions from the sessions and workspaces it
125
+ knows. A directory created by hand with the same name is reused as is.
126
+ - A root reached through a symlink is matched by its canonical path on the
127
+ Host. In the browser, reusing the newest scratch and the root chip compare
128
+ the configured spelling only.
129
+ - The built-in **New Session** of a scratch workspace opens a second session
130
+ in the same directory: the Harness offers no way to change it per workspace.
131
+ - English only.
@@ -0,0 +1,6 @@
1
+ # Installs this plugin on its own (dsh plugin add @1outres/dsh-plugin-scratchpad).
2
+ # The @1outres/dsh-plugins bundle inserts the same "scratchpad" row, so select
3
+ # one bundle or the other, never both.
4
+ - insert:
5
+ - id: scratchpad
6
+ name: "@1outres/dsh-plugin-scratchpad"
package/icon.svg ADDED
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none" role="img" aria-labelledby="title">
2
+ <title id="title">Scratchpad</title>
3
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="#F2A33A"/>
4
+ <path d="M19 13H37L47 23V48C47 49.657 45.657 51 44 51H19C17.343 51 16 49.657 16 48V16C16 14.343 17.343 13 19 13Z" fill="#FFFFFF" fill-opacity="0.95"/>
5
+ <path d="M37 13V20C37 21.657 38.343 23 40 23H47" fill="#FBE3BD"/>
6
+ <path d="M37 13V20C37 21.657 38.343 23 40 23H47" stroke="#C97A12" stroke-width="2" stroke-linejoin="round"/>
7
+ <path d="M22 30H34M22 36H30" stroke="#E0A44E" stroke-width="2.4" stroke-linecap="round"/>
8
+ <circle cx="41" cy="42" r="8" fill="#C97A12"/>
9
+ <path d="M41 38V46M37 42H45" stroke="#FFFFFF" stroke-width="2.4" stroke-linecap="round"/>
10
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,502 @@
1
+ /* Generated by scripts/build.mjs — do not edit. */
2
+ window.__ModuleLoader__.load({
3
+ id: "@1outres/dsh-plugin-scratchpad",
4
+ factory: (require) => {
5
+ var module = { exports: {} };
6
+ var exports = module.exports;
7
+ "use strict";
8
+ var __defProp = Object.defineProperty;
9
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
10
+ var __getOwnPropNames = Object.getOwnPropertyNames;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+ var __copyProps = (to, from, except, desc) => {
17
+ if (from && typeof from === "object" || typeof from === "function") {
18
+ for (let key of __getOwnPropNames(from))
19
+ if (!__hasOwnProp.call(to, key) && key !== except)
20
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
21
+ }
22
+ return to;
23
+ };
24
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
25
+
26
+ // src/client/index.tsx
27
+ var index_exports = {};
28
+ __export(index_exports, {
29
+ COMMAND: () => COMMAND,
30
+ FOOTER_SLOT: () => FOOTER_SLOT,
31
+ HERO_SLOT: () => HERO_SLOT,
32
+ NAMESPACE: () => NAMESPACE,
33
+ apply: () => apply,
34
+ inject: () => inject
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+
38
+ // src/shared/paths.ts
39
+ var DEFAULT_ROOT = "~/.dsh/scratchpad";
40
+ function trimTrailingSlashes(path) {
41
+ let end = path.length;
42
+ while (end > 1 && (path[end - 1] === "/" || path[end - 1] === "\\")) end--;
43
+ return path.slice(0, end);
44
+ }
45
+ function expandHome(path, home) {
46
+ const value = path.trim();
47
+ if (value === "~" || value.startsWith("~/") || value.startsWith("~\\")) {
48
+ if (home === void 0 || home === "") return void 0;
49
+ return trimTrailingSlashes(`${trimTrailingSlashes(home)}${value.slice(1)}`);
50
+ }
51
+ return trimTrailingSlashes(value);
52
+ }
53
+ function isAbsolutePath(path) {
54
+ return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
55
+ }
56
+ function resolveRoot(configured, home) {
57
+ const raw = configured === void 0 || configured.trim() === "" ? DEFAULT_ROOT : configured;
58
+ const expanded = expandHome(raw, home);
59
+ if (expanded === void 0 || !isAbsolutePath(expanded)) return void 0;
60
+ return expanded;
61
+ }
62
+ function separatorOf(root) {
63
+ return /^[A-Za-z]:\\/.test(root) || root.startsWith("\\\\") ? "\\" : "/";
64
+ }
65
+ function joinPath(dir, name) {
66
+ const base = trimTrailingSlashes(dir);
67
+ const sep = separatorOf(base);
68
+ return base.endsWith(sep) ? `${base}${name}` : `${base}${sep}${name}`;
69
+ }
70
+ function splitPath(path) {
71
+ const trimmed = trimTrailingSlashes(path);
72
+ const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
73
+ if (at < 0) return { parent: "", name: trimmed };
74
+ return {
75
+ parent: at === 0 ? trimmed.slice(0, 1) : trimmed.slice(0, at),
76
+ name: trimmed.slice(at + 1)
77
+ };
78
+ }
79
+ function isScratchName(name) {
80
+ return name !== "" && !name.startsWith(".") && !/[\\/]/.test(name);
81
+ }
82
+ function scratchNameOf(path, roots) {
83
+ if (path === void 0) return void 0;
84
+ const { parent, name } = splitPath(path);
85
+ if (!isScratchName(name)) return void 0;
86
+ return roots.some((root) => trimTrailingSlashes(root) === parent) ? name : void 0;
87
+ }
88
+ function isRootPath(path, roots) {
89
+ if (path === void 0) return false;
90
+ const value = trimTrailingSlashes(path);
91
+ return roots.some((root) => trimTrailingSlashes(root) === value);
92
+ }
93
+ function pad(value) {
94
+ return String(value).padStart(2, "0");
95
+ }
96
+ function scratchName(date) {
97
+ return [
98
+ date.getFullYear(),
99
+ pad(date.getMonth() + 1),
100
+ pad(date.getDate()),
101
+ `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
102
+ ].join("-");
103
+ }
104
+ function uniqueName(base, taken) {
105
+ if (!taken(base)) return base;
106
+ for (let n = 2; ; n++) {
107
+ const candidate = `${base}-${n}`;
108
+ if (!taken(candidate)) return candidate;
109
+ }
110
+ }
111
+
112
+ // src/client/components.tsx
113
+ var import_react = require("react");
114
+
115
+ // src/client/icons.tsx
116
+ var import_jsx_runtime = require("react/jsx-runtime");
117
+ function IconScratch({ size = 16, className }) {
118
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
119
+ "svg",
120
+ {
121
+ width: size,
122
+ height: size,
123
+ className,
124
+ viewBox: "0 0 16 16",
125
+ fill: "none",
126
+ "aria-hidden": "true",
127
+ children: [
128
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
129
+ "path",
130
+ {
131
+ d: "M9.5 1.5H4A1.5 1.5 0 0 0 2.5 3v10A1.5 1.5 0 0 0 4 14.5h8a1.5 1.5 0 0 0 1.5-1.5V5.5l-4-4Z",
132
+ stroke: "currentColor",
133
+ strokeLinejoin: "round"
134
+ }
135
+ ),
136
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M9.5 1.5v4h4", stroke: "currentColor", strokeLinejoin: "round" }),
137
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M8 7.5v5M5.5 10h5", stroke: "currentColor", strokeLinecap: "round" })
138
+ ]
139
+ }
140
+ );
141
+ }
142
+
143
+ // src/client/components.tsx
144
+ var import_jsx_runtime2 = require("react/jsx-runtime");
145
+ function useObservable(observable) {
146
+ const subscribe = (0, import_react.useCallback)(
147
+ (listener) => observable.subscribe(listener),
148
+ [observable]
149
+ );
150
+ const getSnapshot = (0, import_react.useCallback)(() => observable.getSnapshot(), [observable]);
151
+ return (0, import_react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
152
+ }
153
+ function NewScratchFooterButton({ wide, t, start, status }) {
154
+ const { busy, error } = useObservable(status);
155
+ const label = busy ? t("action.busy") : error ?? t("action.label");
156
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
157
+ "button",
158
+ {
159
+ type: "button",
160
+ className: "dsh-scratch-footer",
161
+ "data-rail": wide ? void 0 : "",
162
+ disabled: busy,
163
+ title: error ?? t("action.hint"),
164
+ "aria-label": t("action.label"),
165
+ onClick: () => {
166
+ void start();
167
+ },
168
+ children: [
169
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(IconScratch, { size: 16 }),
170
+ wide ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-scratch-label", "data-error": error === void 0 ? void 0 : "", children: label }) : null
171
+ ]
172
+ }
173
+ );
174
+ }
175
+ function NewScratchChip({ t, start, status, sessionId, sessions, isRoot }) {
176
+ const list = useObservable(sessions);
177
+ const { busy, error } = useObservable(status);
178
+ if (!isRoot(list.byId[sessionId]?.cwd)) return null;
179
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
180
+ "button",
181
+ {
182
+ type: "button",
183
+ className: "dsh-scratch-chip",
184
+ disabled: busy,
185
+ title: error ?? t("chip.hint"),
186
+ onClick: () => {
187
+ void start();
188
+ },
189
+ children: [
190
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(IconScratch, { size: 14 }),
191
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: busy ? t("action.busy") : t("chip.label") })
192
+ ]
193
+ }
194
+ );
195
+ }
196
+
197
+ // src/client/i18n.ts
198
+ var en = {
199
+ "action.label": "New scratch",
200
+ "action.hint": "Start a session in a new empty scratch directory",
201
+ "action.busy": "Creating scratch\u2026",
202
+ "chip.label": "New scratch",
203
+ "chip.hint": "This session can write to every scratch. Start in a new empty scratch directory instead",
204
+ "command.label": "New scratch",
205
+ "command.description": "Start a session in a new empty scratch directory",
206
+ "error.root": "The scratch root is not an absolute path, or the Host is not connected yet",
207
+ "error.create": "Could not create the scratch: {detail}"
208
+ };
209
+
210
+ // src/client/scratch.ts
211
+ var REGISTER_TIMEOUT_MS = 15e3;
212
+ var ERROR_HOLD_MS = 6e3;
213
+ var ScratchRootError = class extends Error {
214
+ constructor() {
215
+ super("scratch root unavailable");
216
+ this.name = "ScratchRootError";
217
+ }
218
+ };
219
+ function scratchWorkspaces(snapshot, roots) {
220
+ return snapshot.items.filter((workspace) => scratchNameOf(workspace.path, roots) !== void 0).sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
221
+ }
222
+ function reusableScratch(workspaces, sessions, roots) {
223
+ const [latest] = scratchWorkspaces(workspaces, roots);
224
+ if (latest === void 0) return void 0;
225
+ const archived = new Set(workspaces.archivedSessionIds);
226
+ const unused = latest.sessionIds.every(
227
+ (id) => !archived.has(id) && sessions.byId[id]?.blank === true
228
+ );
229
+ return unused ? latest : void 0;
230
+ }
231
+ function nameTaken(name, workspaces, sessions, roots) {
232
+ if (workspaces.items.some((workspace) => scratchNameOf(workspace.path, roots) === name)) {
233
+ return true;
234
+ }
235
+ return Object.values(sessions.byId).some((row) => scratchNameOf(row?.cwd, roots) === name);
236
+ }
237
+ async function startScratch(deps) {
238
+ const root = deps.root();
239
+ if (root === void 0) throw new ScratchRootError();
240
+ const roots = [root];
241
+ const reusable = reusableScratch(deps.workspaces(), deps.sessions(), roots);
242
+ if (reusable !== void 0) {
243
+ await deps.openWorkspace(reusable.workspaceId);
244
+ return;
245
+ }
246
+ const name = uniqueName(
247
+ scratchName(deps.now()),
248
+ (candidate) => nameTaken(candidate, deps.workspaces(), deps.sessions(), roots)
249
+ );
250
+ const sessionId = await deps.createSession(joinPath(root, name));
251
+ const workspaceId = await deps.waitForWorkspace(sessionId, REGISTER_TIMEOUT_MS);
252
+ if (workspaceId === void 0) deps.openSession(sessionId);
253
+ else await deps.openWorkspace(workspaceId);
254
+ }
255
+ function createScratchStarter(deps, options) {
256
+ let status = { busy: false, error: void 0 };
257
+ let running;
258
+ let clearTimer;
259
+ const listeners = /* @__PURE__ */ new Set();
260
+ const publish = (next) => {
261
+ status = next;
262
+ for (const listener of [...listeners]) listener();
263
+ };
264
+ const message = (error) => error instanceof ScratchRootError ? options.t("error.root") : options.t("error.create", {
265
+ detail: error instanceof Error ? error.message : String(error)
266
+ });
267
+ return {
268
+ start() {
269
+ if (running !== void 0) return running;
270
+ if (clearTimer !== void 0) clearTimeout(clearTimer);
271
+ publish({ busy: true, error: void 0 });
272
+ running = startScratch(deps).then(
273
+ () => {
274
+ running = void 0;
275
+ publish({ busy: false, error: void 0 });
276
+ },
277
+ (error) => {
278
+ running = void 0;
279
+ options.warn("scratchpad: could not start a scratch", error);
280
+ publish({ busy: false, error: message(error) });
281
+ clearTimer = setTimeout(() => publish({ busy: false, error: void 0 }), ERROR_HOLD_MS);
282
+ }
283
+ );
284
+ return running;
285
+ },
286
+ status: {
287
+ getSnapshot: () => status,
288
+ subscribe(listener) {
289
+ listeners.add(listener);
290
+ return () => {
291
+ listeners.delete(listener);
292
+ };
293
+ }
294
+ },
295
+ dispose() {
296
+ if (clearTimer !== void 0) clearTimeout(clearTimer);
297
+ listeners.clear();
298
+ }
299
+ };
300
+ }
301
+ function waitForWorkspace(list, sessionId, timeoutMs) {
302
+ const find = () => list.getSnapshot().items.find((workspace) => workspace.sessionIds.includes(sessionId))?.workspaceId;
303
+ const found = find();
304
+ if (found !== void 0) return Promise.resolve(found);
305
+ return new Promise((resolve) => {
306
+ let done = false;
307
+ let unsubscribe;
308
+ const finish = (value) => {
309
+ if (done) return;
310
+ done = true;
311
+ clearTimeout(timer);
312
+ unsubscribe?.();
313
+ resolve(value);
314
+ };
315
+ const timer = setTimeout(() => finish(void 0), timeoutMs);
316
+ unsubscribe = list.subscribe(() => {
317
+ const workspaceId = find();
318
+ if (workspaceId !== void 0) finish(workspaceId);
319
+ });
320
+ if (done) unsubscribe();
321
+ });
322
+ }
323
+
324
+ // src/client/styles.ts
325
+ var SCRATCH_CSS = `
326
+ .dsh-scratch-footer {
327
+ box-sizing: border-box;
328
+ width: calc(100% + 4px);
329
+ height: 42px;
330
+ margin: 8px -2px 0;
331
+ padding: 0 10px 0 8px;
332
+ border: none;
333
+ border-radius: 12px;
334
+ background: transparent;
335
+ color: var(--dsw-alias-label-primary);
336
+ font-family: inherit;
337
+ font-size: 14px;
338
+ display: inline-flex;
339
+ align-items: center;
340
+ gap: 8px;
341
+ cursor: pointer;
342
+ overflow: hidden;
343
+ }
344
+ .dsh-scratch-footer:hover:not(:disabled) {
345
+ background: var(--dsw-alias-interactive-bg-hover);
346
+ }
347
+ .dsh-scratch-footer:disabled {
348
+ cursor: progress;
349
+ opacity: 0.6;
350
+ }
351
+ .dsh-scratch-footer[data-rail] {
352
+ width: 36px;
353
+ height: 36px;
354
+ margin: 0;
355
+ padding: 0;
356
+ gap: 0;
357
+ justify-content: center;
358
+ border-radius: 50%;
359
+ }
360
+ .dsh-scratch-footer svg {
361
+ flex: none;
362
+ }
363
+ .dsh-scratch-label {
364
+ min-width: 0;
365
+ white-space: nowrap;
366
+ overflow: hidden;
367
+ text-overflow: ellipsis;
368
+ }
369
+ .dsh-scratch-label[data-error] {
370
+ color: var(--dsw-alias-state-error-primary);
371
+ }
372
+ .dsh-scratch-chip {
373
+ box-sizing: border-box;
374
+ height: 28px;
375
+ padding: 0 10px;
376
+ border: 0.5px solid var(--dsw-alias-border-l3);
377
+ border-radius: 999px;
378
+ background: transparent;
379
+ color: var(--dsw-alias-label-secondary);
380
+ font-family: inherit;
381
+ font-size: 13px;
382
+ display: inline-flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ white-space: nowrap;
386
+ cursor: pointer;
387
+ }
388
+ .dsh-scratch-chip:hover:not(:disabled) {
389
+ background: var(--dsw-alias-interactive-bg-hover);
390
+ color: var(--dsw-alias-label-primary);
391
+ }
392
+ .dsh-scratch-chip:disabled {
393
+ cursor: progress;
394
+ opacity: 0.6;
395
+ }
396
+ `;
397
+
398
+ // src/client/index.tsx
399
+ var inject = [
400
+ "slots",
401
+ "locale",
402
+ "configForms",
403
+ "sessions",
404
+ "workspaces",
405
+ "uiWorkspace",
406
+ "remote"
407
+ ];
408
+ var PLUGIN_ID = "@1outres/dsh-plugin-scratchpad";
409
+ var NAMESPACE = "scratchpad";
410
+ var FOOTER_SLOT = "sidebar.footer.action";
411
+ var HERO_SLOT = "conversation.hero.options";
412
+ var COMMAND = "scratch";
413
+ function apply(ctx) {
414
+ ctx.effect(() => ctx.locale.register(NAMESPACE, { en }), "scratchpad: dictionaries");
415
+ ctx.effect(() => {
416
+ const tag = document.createElement("style");
417
+ tag.dataset.plugin = PLUGIN_ID;
418
+ tag.dataset.pluginCss = `${PLUGIN_ID}/scratch.css`;
419
+ tag.textContent = SCRATCH_CSS;
420
+ document.head.append(tag);
421
+ return () => {
422
+ tag.remove();
423
+ };
424
+ }, "scratchpad: stylesheet");
425
+ const t = ctx.locale.bind(NAMESPACE);
426
+ const settings = ctx.configForms.get(NAMESPACE);
427
+ ctx.effect(() => settings.subscribe(() => {
428
+ }), "scratchpad: settings");
429
+ const root = () => resolveRoot(settings.getSnapshot().value?.root, ctx.remote.$host.home);
430
+ const starter = createScratchStarter(
431
+ {
432
+ root,
433
+ workspaces: () => ctx.workspaces.list.getSnapshot(),
434
+ sessions: () => ctx.sessions.list.getSnapshot(),
435
+ createSession: (cwd) => ctx.sessions.create({ cwd }),
436
+ openWorkspace: (workspaceId) => ctx.uiWorkspace.openWorkspace(workspaceId),
437
+ openSession: (sessionId) => ctx.uiWorkspace.openSession(sessionId),
438
+ waitForWorkspace: (sessionId, timeoutMs) => waitForWorkspace(ctx.workspaces.list, sessionId, timeoutMs),
439
+ now: () => /* @__PURE__ */ new Date()
440
+ },
441
+ { t, warn: (message, error) => console.warn(message, error) }
442
+ );
443
+ ctx.effect(() => () => starter.dispose(), "scratchpad: starter");
444
+ const face = { start: () => starter.start(), status: starter.status };
445
+ ctx.effect(
446
+ () => ctx.slots.inject(
447
+ FOOTER_SLOT,
448
+ () => ctx.slots.register(
449
+ { name: FOOTER_SLOT, id: "scratchpad", locale: NAMESPACE, inject: () => face },
450
+ NewScratchFooterButton
451
+ )
452
+ ),
453
+ "scratchpad: sidebar button"
454
+ );
455
+ ctx.effect(
456
+ () => ctx.slots.inject(
457
+ HERO_SLOT,
458
+ () => ctx.slots.register(
459
+ {
460
+ name: HERO_SLOT,
461
+ id: "scratchpad",
462
+ locale: NAMESPACE,
463
+ inject: (sessionId) => ({
464
+ ...face,
465
+ sessionId,
466
+ sessions: ctx.sessions.list,
467
+ isRoot: (cwd) => {
468
+ const resolved = root();
469
+ return resolved !== void 0 && isRootPath(cwd, [resolved]);
470
+ }
471
+ })
472
+ },
473
+ NewScratchChip
474
+ )
475
+ ),
476
+ "scratchpad: new session chip"
477
+ );
478
+ ctx.inject(["commandUi"], (child) => {
479
+ const commandUi = child.commandUi;
480
+ if (commandUi === void 0) return;
481
+ child.effect(
482
+ () => commandUi.register({
483
+ name: COMMAND,
484
+ label: () => t("command.label"),
485
+ description: () => t("command.description"),
486
+ icon: IconScratch,
487
+ available: () => true,
488
+ ui: {
489
+ kind: "action",
490
+ run: () => {
491
+ void starter.start();
492
+ }
493
+ }
494
+ }),
495
+ "scratchpad: /scratch command"
496
+ );
497
+ });
498
+ }
499
+
500
+ return module.exports;
501
+ }
502
+ });
package/lib/index.js ADDED
@@ -0,0 +1,469 @@
1
+ // src/index.ts
2
+ import { homedir } from "node:os";
3
+ import z from "@deepseek-ai/schemastery";
4
+
5
+ // src/host/fs.ts
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ realpathSync,
11
+ renameSync,
12
+ rmdirSync,
13
+ statSync
14
+ } from "node:fs";
15
+ var nodeFs = {
16
+ exists: (path) => existsSync(path),
17
+ isEmptyDir: (path) => {
18
+ try {
19
+ return statSync(path).isDirectory() && readdirSync(path).length === 0;
20
+ } catch {
21
+ return false;
22
+ }
23
+ },
24
+ rename: (from, to) => renameSync(from, to),
25
+ removeEmptyDir: (path) => rmdirSync(path),
26
+ ensureDir: (path) => {
27
+ mkdirSync(path, { recursive: true });
28
+ },
29
+ realpath: (path) => {
30
+ try {
31
+ return realpathSync(path);
32
+ } catch {
33
+ return void 0;
34
+ }
35
+ },
36
+ listDirs: (path) => {
37
+ try {
38
+ return readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+ };
44
+
45
+ // src/shared/paths.ts
46
+ var DEFAULT_ROOT = "~/.dsh/scratchpad";
47
+ var ARCHIVE_DIR = ".archived";
48
+ function trimTrailingSlashes(path) {
49
+ let end = path.length;
50
+ while (end > 1 && (path[end - 1] === "/" || path[end - 1] === "\\")) end--;
51
+ return path.slice(0, end);
52
+ }
53
+ function expandHome(path, home) {
54
+ const value = path.trim();
55
+ if (value === "~" || value.startsWith("~/") || value.startsWith("~\\")) {
56
+ if (home === void 0 || home === "") return void 0;
57
+ return trimTrailingSlashes(`${trimTrailingSlashes(home)}${value.slice(1)}`);
58
+ }
59
+ return trimTrailingSlashes(value);
60
+ }
61
+ function isAbsolutePath(path) {
62
+ return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
63
+ }
64
+ function resolveRoot(configured, home) {
65
+ const raw = configured === void 0 || configured.trim() === "" ? DEFAULT_ROOT : configured;
66
+ const expanded = expandHome(raw, home);
67
+ if (expanded === void 0 || !isAbsolutePath(expanded)) return void 0;
68
+ return expanded;
69
+ }
70
+ function separatorOf(root) {
71
+ return /^[A-Za-z]:\\/.test(root) || root.startsWith("\\\\") ? "\\" : "/";
72
+ }
73
+ function joinPath(dir, name2) {
74
+ const base = trimTrailingSlashes(dir);
75
+ const sep = separatorOf(base);
76
+ return base.endsWith(sep) ? `${base}${name2}` : `${base}${sep}${name2}`;
77
+ }
78
+ function splitPath(path) {
79
+ const trimmed = trimTrailingSlashes(path);
80
+ const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
81
+ if (at < 0) return { parent: "", name: trimmed };
82
+ return {
83
+ parent: at === 0 ? trimmed.slice(0, 1) : trimmed.slice(0, at),
84
+ name: trimmed.slice(at + 1)
85
+ };
86
+ }
87
+ function isScratchName(name2) {
88
+ return name2 !== "" && !name2.startsWith(".") && !/[\\/]/.test(name2);
89
+ }
90
+ function scratchNameOf(path, roots) {
91
+ if (path === void 0) return void 0;
92
+ const { parent, name: name2 } = splitPath(path);
93
+ if (!isScratchName(name2)) return void 0;
94
+ return roots.some((root) => trimTrailingSlashes(root) === parent) ? name2 : void 0;
95
+ }
96
+
97
+ // src/host/scratchpad.ts
98
+ var SETTLE_TIMEOUT_MS = 12e4;
99
+ var POLL_MS = 2e3;
100
+ function describe(error) {
101
+ return error instanceof Error ? error.message : String(error);
102
+ }
103
+ var Scratchpad = class {
104
+ options;
105
+ headers = /* @__PURE__ */ new Map();
106
+ /** Session id to scratch name, for top-level Sessions created in a scratch. */
107
+ nameOf = /* @__PURE__ */ new Map();
108
+ archived = /* @__PURE__ */ new Set();
109
+ chains = /* @__PURE__ */ new Map();
110
+ abort = new AbortController();
111
+ early = [];
112
+ canonicalRoot;
113
+ ready = false;
114
+ constructor(options) {
115
+ this.options = options;
116
+ }
117
+ /** Canonical root once it exists, otherwise the configured spelling. */
118
+ rootPath() {
119
+ if (this.canonicalRoot === void 0) {
120
+ this.canonicalRoot = this.options.fs.realpath(this.options.root);
121
+ }
122
+ return this.canonicalRoot ?? this.options.root;
123
+ }
124
+ /** Every spelling a Session cwd may use for the root. */
125
+ roots() {
126
+ const canonical = this.rootPath();
127
+ return canonical === this.options.root ? [canonical] : [this.options.root, canonical];
128
+ }
129
+ /** Directory of a scratch while it is in use. */
130
+ pathOf(name2) {
131
+ return joinPath(this.rootPath(), name2);
132
+ }
133
+ /** Directory of a scratch while it is archived. */
134
+ archivePathOf(name2) {
135
+ return joinPath(joinPath(this.rootPath(), ARCHIVE_DIR), name2);
136
+ }
137
+ /** Index the existing Sessions, then catch up with changes made while the plugin was off. */
138
+ async start() {
139
+ this.archived = new Set(this.options.registry.archivedSessionIds);
140
+ await this.loadHeaders();
141
+ this.ready = true;
142
+ for (const [sessionId, source] of this.early.splice(0)) this.onSessionStarted(sessionId, source);
143
+ const names = new Set(this.nameOf.values());
144
+ await Promise.all([...names].map((name2) => this.enqueue(name2, () => this.reconcile(name2))));
145
+ }
146
+ /** Stop waiting moves. Directories are never left half-moved: each move is one rename. */
147
+ dispose() {
148
+ this.abort.abort();
149
+ }
150
+ /** Resolve once no operation is queued. */
151
+ async idle() {
152
+ while (this.chains.size > 0) await Promise.all([...this.chains.values()]);
153
+ }
154
+ /**
155
+ * An Agent started for a Session (`agent/created`). Registers a new scratch,
156
+ * or moves an archived scratch back when a Session that is not archived needs
157
+ * it, such as a fork made from an archived row.
158
+ */
159
+ onSessionStarted(sessionId, source) {
160
+ const header = this.options.liveHeader(sessionId);
161
+ if (header === void 0) return;
162
+ this.remember(header);
163
+ if (!this.ready) {
164
+ this.early.push([sessionId, source]);
165
+ return;
166
+ }
167
+ const name2 = this.nameOf.get(sessionId);
168
+ if (name2 === void 0 || this.archived.has(sessionId)) return;
169
+ const { fs } = this.options;
170
+ const path = this.pathOf(name2);
171
+ const shared = this.members(name2).some((member) => member.id !== sessionId);
172
+ if (shared && fs.exists(this.archivePathOf(name2)) && (!fs.exists(path) || fs.isEmptyDir(path))) {
173
+ if (this.restoreDirectory(name2)) void this.enqueue(name2, () => this.register(name2));
174
+ return;
175
+ }
176
+ if (source === "startup" && !shared && fs.exists(path) && this.workspaceFor(name2) === void 0) {
177
+ void this.enqueue(name2, () => this.registerNew(name2, sessionId));
178
+ }
179
+ }
180
+ /**
181
+ * The registry's archive set changed (`domain/changed`). Unarchived scratches
182
+ * move back before this returns; archived ones move once their work stops.
183
+ */
184
+ onArchiveSetChanged(next) {
185
+ const previous = this.archived;
186
+ this.archived = new Set(next);
187
+ if (!this.ready) return;
188
+ for (const id of previous) {
189
+ if (this.archived.has(id)) continue;
190
+ const name2 = this.nameOf.get(id);
191
+ if (name2 === void 0 || !this.options.fs.exists(this.archivePathOf(name2))) continue;
192
+ if (this.restoreDirectory(name2)) void this.enqueue(name2, () => this.register(name2));
193
+ }
194
+ const archivedNames = /* @__PURE__ */ new Set();
195
+ for (const id of this.archived) {
196
+ if (previous.has(id)) continue;
197
+ const name2 = this.nameOf.get(id);
198
+ if (name2 !== void 0) archivedNames.add(name2);
199
+ }
200
+ for (const name2 of archivedNames) void this.enqueue(name2, () => this.archiveWhenSettled(name2));
201
+ }
202
+ remember(header) {
203
+ this.headers.set(header.id, header);
204
+ if (header.origin === "subagent") return;
205
+ const name2 = scratchNameOf(header.cwd, this.roots());
206
+ if (name2 !== void 0) this.nameOf.set(header.id, name2);
207
+ }
208
+ async loadHeaders() {
209
+ for (const header of await this.options.headers()) this.remember(header);
210
+ }
211
+ /** Top-level Sessions created in a scratch, oldest first. */
212
+ members(name2) {
213
+ const members = [];
214
+ for (const [id, scratch] of this.nameOf) {
215
+ const header = this.headers.get(id);
216
+ if (scratch === name2 && header !== void 0) members.push(header);
217
+ }
218
+ return members.sort((left, right) => left.createdAt - right.createdAt);
219
+ }
220
+ workspaceFor(name2) {
221
+ const path = this.pathOf(name2);
222
+ return this.options.registry.list().find((workspace) => workspace.path === path);
223
+ }
224
+ allArchived(workspace) {
225
+ const ids = workspace.sessionIds;
226
+ return ids.length > 0 && ids.every((id) => this.archived.has(id));
227
+ }
228
+ enqueue(name2, operation) {
229
+ const previous = this.chains.get(name2) ?? Promise.resolve();
230
+ const next = previous.then(() => this.abort.signal.aborted ? void 0 : operation()).catch((error) => {
231
+ this.options.logger.warn(`scratchpad: ${name2}: ${describe(error)}`);
232
+ });
233
+ this.chains.set(name2, next);
234
+ void next.then(() => {
235
+ if (this.chains.get(name2) === next) this.chains.delete(name2);
236
+ });
237
+ return next;
238
+ }
239
+ /** Move an archived scratch back to the root. Synchronous. */
240
+ restoreDirectory(name2) {
241
+ const { fs, logger } = this.options;
242
+ const from = this.archivePathOf(name2);
243
+ const to = this.pathOf(name2);
244
+ try {
245
+ if (fs.exists(to)) {
246
+ if (!fs.isEmptyDir(to)) {
247
+ logger.warn(`scratchpad: ${name2} stays archived: ${to} already exists and is not empty`);
248
+ return false;
249
+ }
250
+ fs.removeEmptyDir(to);
251
+ }
252
+ fs.rename(from, to);
253
+ } catch (error) {
254
+ logger.warn(`scratchpad: could not restore ${name2}: ${describe(error)}`);
255
+ return false;
256
+ }
257
+ logger.info(`scratchpad: restored ${name2} from the archive`);
258
+ return true;
259
+ }
260
+ /** Register a restored scratch and give it back its Sessions, newest first. */
261
+ async register(name2) {
262
+ const path = this.pathOf(name2);
263
+ if (!this.options.fs.exists(path)) return;
264
+ await this.loadHeaders();
265
+ const workspace = this.workspaceFor(name2) ?? await this.options.registry.create(path, name2);
266
+ for (const member of this.members(name2)) {
267
+ if (workspace.sessionIds.includes(member.id)) continue;
268
+ try {
269
+ await workspace.attachSession(member.id);
270
+ } catch (error) {
271
+ this.options.logger.warn(
272
+ `scratchpad: could not attach ${member.id} to ${name2}: ${describe(error)}`
273
+ );
274
+ }
275
+ }
276
+ }
277
+ /** Register a scratch that a new Session was just created in. */
278
+ async registerNew(name2, sessionId) {
279
+ const { registry, registerRoot } = this.options;
280
+ const root = this.rootPath();
281
+ if (registerRoot && !registry.list().some((workspace2) => workspace2.path === root)) {
282
+ await registry.create(root);
283
+ }
284
+ const workspace = this.workspaceFor(name2) ?? await registry.create(this.pathOf(name2), name2);
285
+ if (!workspace.sessionIds.includes(sessionId)) await workspace.attachSession(sessionId);
286
+ }
287
+ /** Move a registered scratch to the archive once all its Sessions are archived and idle. */
288
+ async archiveWhenSettled(name2) {
289
+ const { fs, logger, registry } = this.options;
290
+ const workspace = this.workspaceFor(name2);
291
+ if (workspace === void 0 || !this.allArchived(workspace)) return;
292
+ const path = this.pathOf(name2);
293
+ const target = this.archivePathOf(name2);
294
+ if (!fs.exists(path)) {
295
+ if (fs.exists(target)) await registry.delete(workspace.id);
296
+ return;
297
+ }
298
+ if (!await this.settle(name2, workspace)) return;
299
+ const current = this.workspaceFor(name2);
300
+ if (current === void 0 || !this.allArchived(current)) return;
301
+ if (fs.exists(target)) {
302
+ logger.warn(`scratchpad: ${name2} stays in place: ${target} already exists`);
303
+ return;
304
+ }
305
+ try {
306
+ fs.ensureDir(joinPath(this.rootPath(), ARCHIVE_DIR));
307
+ fs.rename(path, target);
308
+ } catch (error) {
309
+ logger.warn(`scratchpad: could not archive ${name2}: ${describe(error)}`);
310
+ return;
311
+ }
312
+ logger.info(`scratchpad: archived ${name2} to ${target}`);
313
+ await registry.delete(current.id);
314
+ }
315
+ /** Wait until no Session of the scratch runs anything. */
316
+ async settle(name2, workspace) {
317
+ const { now, sleep: sleep2, busy, logger } = this.options;
318
+ const deadline = now() + (this.options.settleTimeoutMs ?? SETTLE_TIMEOUT_MS);
319
+ const poll = this.options.pollMs ?? POLL_MS;
320
+ for (; ; ) {
321
+ if (this.abort.signal.aborted || !this.allArchived(workspace)) return false;
322
+ let running = false;
323
+ for (const id of workspace.sessionIds) {
324
+ try {
325
+ running = await busy(id);
326
+ } catch {
327
+ running = true;
328
+ }
329
+ if (running) break;
330
+ }
331
+ if (!running) return true;
332
+ if (now() >= deadline) {
333
+ logger.warn(`scratchpad: ${name2} stays in place: its sessions are still running`);
334
+ return false;
335
+ }
336
+ try {
337
+ await sleep2(poll, this.abort.signal);
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+ }
343
+ /** Catch up one scratch at startup. */
344
+ async reconcile(name2) {
345
+ const members = this.members(name2);
346
+ if (members.length === 0) return;
347
+ if (members.every((member) => this.archived.has(member.id))) {
348
+ await this.archiveWhenSettled(name2);
349
+ return;
350
+ }
351
+ const { fs } = this.options;
352
+ const path = this.pathOf(name2);
353
+ if (fs.exists(this.archivePathOf(name2)) && (!fs.exists(path) || fs.isEmptyDir(path))) {
354
+ if (this.restoreDirectory(name2)) await this.register(name2);
355
+ }
356
+ }
357
+ };
358
+
359
+ // src/index.ts
360
+ var name = "scratchpad";
361
+ var inject = ["workspaceRegistry", "sessionPersistence", "sessions"];
362
+ var Config = z.object({
363
+ root: z.string().default(DEFAULT_ROOT).description("Directory that holds the scratches. A leading ~ is your home directory."),
364
+ registerRoot: z.boolean().default(true).description(
365
+ "Register the root as a workspace when a scratch is created, so Workspace Tree grouping nests every scratch under it."
366
+ )
367
+ });
368
+ function archivedIdsOf(change) {
369
+ if (change.domain !== "workspace" || change.table !== "" || change.operation !== "put") {
370
+ return void 0;
371
+ }
372
+ const value = change.value;
373
+ if (typeof value !== "object" || value === null || !Array.isArray(value.workspaceIds)) {
374
+ return void 0;
375
+ }
376
+ const ids = value.archivedSessionIds ?? [];
377
+ if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string")) return void 0;
378
+ return ids;
379
+ }
380
+ function sleep(ms, signal) {
381
+ return new Promise((resolve, reject) => {
382
+ if (signal.aborted) {
383
+ reject(signal.reason);
384
+ return;
385
+ }
386
+ const timer = setTimeout(() => {
387
+ signal.removeEventListener("abort", stop);
388
+ resolve();
389
+ }, ms);
390
+ const stop = () => {
391
+ clearTimeout(timer);
392
+ reject(signal.reason);
393
+ };
394
+ signal.addEventListener("abort", stop, { once: true });
395
+ });
396
+ }
397
+ function apply(ctx, config) {
398
+ const services = ctx;
399
+ const events = ctx;
400
+ const logger = {
401
+ info: (message) => ctx.logger.info(message),
402
+ warn: (message) => ctx.logger.warn(message)
403
+ };
404
+ const root = resolveRoot(config.root, homedir());
405
+ if (root === void 0) {
406
+ logger.warn(`scratchpad: root '${config.root}' is not an absolute path; nothing is managed`);
407
+ return;
408
+ }
409
+ const pad = new Scratchpad({
410
+ root,
411
+ registerRoot: config.registerRoot,
412
+ registry: services.workspaceRegistry,
413
+ headers: async () => {
414
+ const byId = /* @__PURE__ */ new Map();
415
+ for (const session of await services.sessionPersistence.list()) {
416
+ byId.set(session.header.id, session.header);
417
+ }
418
+ for (const session of services.sessions.list()) byId.set(session.header.id, session.header);
419
+ return [...byId.values()];
420
+ },
421
+ liveHeader: (sessionId) => services.sessions.get(sessionId)?.header,
422
+ busy: async (sessionId) => {
423
+ const activity = await events.waterfall(
424
+ "workspace/session-activity",
425
+ { sessionId },
426
+ () => Promise.resolve([])
427
+ );
428
+ if (activity.length > 0) return true;
429
+ const agent = ctx.get("agents")?.get(sessionId);
430
+ const terminals = ctx.get("terminals");
431
+ return agent !== void 0 && terminals?.hasOwnerActivity(agent) === true;
432
+ },
433
+ fs: nodeFs,
434
+ logger,
435
+ sleep,
436
+ now: Date.now
437
+ });
438
+ events.on("agent/created", (payload) => {
439
+ try {
440
+ pad.onSessionStarted(payload.agent.id, payload.source);
441
+ } catch (error) {
442
+ logger.warn(`scratchpad: ${error instanceof Error ? error.message : String(error)}`);
443
+ }
444
+ });
445
+ events.on("domain/changed", (change) => {
446
+ const archived = archivedIdsOf(change);
447
+ if (archived === void 0) return;
448
+ try {
449
+ pad.onArchiveSetChanged(archived);
450
+ } catch (error) {
451
+ logger.warn(`scratchpad: ${error instanceof Error ? error.message : String(error)}`);
452
+ }
453
+ });
454
+ ctx.effect(() => {
455
+ pad.start().catch((error) => {
456
+ logger.warn(
457
+ `scratchpad: startup check failed: ${error instanceof Error ? error.message : String(error)}`
458
+ );
459
+ });
460
+ return () => pad.dispose();
461
+ }, "scratchpad: archive bookkeeping");
462
+ }
463
+ export {
464
+ Config,
465
+ apply,
466
+ archivedIdsOf,
467
+ inject,
468
+ name
469
+ };
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Scratchpad",
4
+ "description": "One click opens a session in a new empty directory, registered as its own workspace, for one-off scripts, experiments, research and writing. Archiving every session in a scratch moves its directory to .archived; unarchiving moves it back."
5
+ }
6
+ }
package/locale/ja.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "スクラッチパッド",
4
+ "description": "ワンクリックで新しい空のディレクトリを作り、専用のワークスペースとして Session を開きます。使い捨てのスクリプトや小さな実験、調査、執筆に。スクラッチの Session をすべてアーカイブするとディレクトリを .archived へ移し、アーカイブを解除すると元に戻します。"
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "草稿区",
4
+ "description": "一键在新建的空目录中打开会话,该目录注册为独立的工作区,适合一次性脚本、小实验、调研和写作。草稿中的会话全部归档后,其目录会移入 .archived;取消归档时移回原处。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@1outres/dsh-plugin-scratchpad",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.5.0",
4
+ "description": "One-click scratch sessions for DeepSeek Harness: each gets a new empty directory registered as its own workspace, moved aside when archived.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/1outres/my-dsh-plugins.git",
8
+ "directory": "packages/dsh-plugin-scratchpad"
9
+ },
10
+ "type": "module",
11
+ "main": "lib/index.js",
12
+ "exports": {
13
+ ".": "./lib/index.js",
14
+ "./client": "./lib/client.js",
15
+ "./locale/*.json": "./locale/*.json",
16
+ "./icon": "./icon.svg",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "lib",
21
+ "cordis.patch.yml",
22
+ "locale/*.json",
23
+ "icon.svg"
24
+ ],
25
+ "keywords": [
26
+ "dsh",
27
+ "dsh-plugin",
28
+ "scratchpad",
29
+ "deepseek-harness"
30
+ ],
31
+ "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "engines": {
36
+ "dsh": ">=0.2.1-rc.5 <0.3.0",
37
+ "node": ">=22"
38
+ },
39
+ "dependencies": {
40
+ "@deepseek-ai/schemastery": "~3.18.5"
41
+ },
42
+ "peerDependencies": {
43
+ "@deepseek-ai/cordis": "~4.0.5",
44
+ "react": "^18.2.0"
45
+ },
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": [
49
+ "./cordis.patch.yml"
50
+ ]
51
+ },
52
+ "client": {
53
+ "platform": "web",
54
+ "inject": [
55
+ "@deepseek-ai/dsh-client-ui-renderer",
56
+ "@deepseek-ai/dsh-client-ui-sidebar",
57
+ "@deepseek-ai/dsh-client-ui-conversation",
58
+ "@deepseek-ai/dsh-client-ui-commands",
59
+ "@deepseek-ai/dsh-client-ui-settings",
60
+ "@deepseek-ai/dsh-api-remotes",
61
+ "@deepseek-ai/dsh-api-session-controller",
62
+ "@deepseek-ai/dsh-api-workspace-controller",
63
+ "@deepseek-ai/dsh-client-ui-workspace"
64
+ ]
65
+ }
66
+ },
67
+ "scripts": {}
6
68
  }