@1outres/dsh-plugin-scratchpad 0.0.0-stage → 0.6.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.md +130 -2
- package/cordis.patch.yml +6 -0
- package/icon.svg +10 -0
- package/lib/client.js +502 -0
- package/lib/index.js +469 -0
- package/locale/en.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -3
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
|
-
#
|
|
1
|
+
# @1outres/dsh-plugin-scratchpad
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/cordis.patch.yml
ADDED
|
@@ -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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1outres/dsh-plugin-scratchpad",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.6.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
|
}
|