@huanlin/dsh-plugin-interpreters 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +664 -0
- package/README.md +69 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +457 -0
- package/lib/config.js +29 -0
- package/lib/gateway.js +163 -0
- package/lib/index.js +58 -0
- package/lib/runner.js +120 -0
- package/lib/settings.js +83 -0
- package/lib/tools.js +110 -0
- package/lib/types/client/InterpretersCard.d.ts +42 -0
- package/lib/types/client/InterpretersCard.js +74 -0
- package/lib/types/client/index.d.ts +39 -0
- package/lib/types/client/index.js +72 -0
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/locales.js +50 -0
- package/lib/types/client/store.d.ts +88 -0
- package/lib/types/client/store.js +196 -0
- package/lib/types/config.d.ts +32 -0
- package/lib/types/config.js +29 -0
- package/lib/types/gateway.d.ts +63 -0
- package/lib/types/gateway.js +163 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/index.js +58 -0
- package/lib/types/runner.d.ts +31 -0
- package/lib/types/runner.js +120 -0
- package/lib/types/settings.d.ts +43 -0
- package/lib/types/settings.js +83 -0
- package/lib/types/tools.d.ts +41 -0
- package/lib/types/tools.js +110 -0
- package/package.json +102 -0
package/lib/gateway.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gateway.ts — host-side HTTP gateway exposing the `interpreters` config to
|
|
3
|
+
* the browser through a self-hosted `/interpreters/api` route.
|
|
4
|
+
*
|
|
5
|
+
* The DSH typertGateway `/api` RPC dispatch was the original channel
|
|
6
|
+
* (TypertRemoteService + @Remote), but the host's SRC discovery
|
|
7
|
+
* (ctx.reflect.props enumeration) is not claiming plugin-owned service
|
|
8
|
+
* endpoints on the current dsh snapshot. The self-hosted HTTP route
|
|
9
|
+
* mirrors the better-sidebar pattern: `ctx.webServer.register` claims a
|
|
10
|
+
* prefix route, the handler reads/writes the settings seam in-process
|
|
11
|
+
* (no wire-layer allowlist gate), and the browser reaches it through
|
|
12
|
+
* `fetch('/interpreters/api/<method>')`.
|
|
13
|
+
*
|
|
14
|
+
* Route shape:
|
|
15
|
+
* POST /interpreters/api/get → { ok: true, value: { config: ResolvedConfig } }
|
|
16
|
+
* POST /interpreters/api/set body: { patch: Partial<Config> }
|
|
17
|
+
* → { ok: true, value: { config: ResolvedConfig } }
|
|
18
|
+
* Errors carry { ok: false, error: { code, message } }.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-interpreters/gateway
|
|
21
|
+
*/
|
|
22
|
+
import { resolveConfig, } from './config.js';
|
|
23
|
+
import { SETTINGS_NAMESPACE, } from './settings.js';
|
|
24
|
+
/** HTTP route prefix owning every interpreters API request. */
|
|
25
|
+
const API_PREFIX = '/interpreters/api';
|
|
26
|
+
/** Config keys the `set` endpoint accepts (allow-list; unknown keys are dropped). */
|
|
27
|
+
const ALLOWED_KEYS = new Set(['pythonPath', 'nodePath', 'timeoutMs']);
|
|
28
|
+
/**
|
|
29
|
+
* Register the `/interpreters/api` HTTP route on the host's web server.
|
|
30
|
+
*
|
|
31
|
+
* The route reads/writes the `interpreters` settings namespace in-process
|
|
32
|
+
* through the bridge + `ctx.settings`. The settings service is optional:
|
|
33
|
+
* when absent, `get` degrades to the entry source and `set` returns a
|
|
34
|
+
* clear error.
|
|
35
|
+
* @param ctx - host context carrying `webServer`.
|
|
36
|
+
* @param bridge - the settings bridge the route reads through.
|
|
37
|
+
*/
|
|
38
|
+
export function registerHttpGateway(ctx, bridge) {
|
|
39
|
+
let settings;
|
|
40
|
+
ctx.inject(['settings'], (sctx) => {
|
|
41
|
+
settings = sctx.settings;
|
|
42
|
+
return () => { settings = undefined; };
|
|
43
|
+
});
|
|
44
|
+
ctx.effect(() => ctx.webServer.register({
|
|
45
|
+
kind: 'prefix',
|
|
46
|
+
path: API_PREFIX,
|
|
47
|
+
handler: async (req, res) => {
|
|
48
|
+
if (req.method !== 'POST') {
|
|
49
|
+
writeJson(res, 405, envelopeError('method-not-allowed', 'POST only'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
|
53
|
+
const method = pathname.startsWith(`${API_PREFIX}/`)
|
|
54
|
+
? pathname.slice(`${API_PREFIX}/`.length)
|
|
55
|
+
: undefined;
|
|
56
|
+
if (method === undefined || method.includes('/')) {
|
|
57
|
+
writeJson(res, 404, envelopeError('not-found', 'unknown interpreters API method'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const body = await readJsonBody(req);
|
|
62
|
+
if (method === 'get') {
|
|
63
|
+
const config = resolveConfig(bridge.source());
|
|
64
|
+
writeJson(res, 200, envelopeOk({ config }));
|
|
65
|
+
}
|
|
66
|
+
else if (method === 'set') {
|
|
67
|
+
const result = await handleSet(body, settings, bridge);
|
|
68
|
+
writeJson(res, 200, envelopeOk(result));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
writeJson(res, 404, envelopeError('not-found', `unknown interpreters API method "${method}"`));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
writeJson(res, 500, envelopeError('internal', message));
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
}), 'dsh-interpreters: /interpreters/api routes');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Handle the `set` method: validate the patch, write the user layer, return
|
|
83
|
+
* the new resolved config.
|
|
84
|
+
* @param body - the parsed JSON body from the request.
|
|
85
|
+
* @param settings - the live settings service (undefined when unavailable).
|
|
86
|
+
* @param bridge - the settings bridge for reading the source.
|
|
87
|
+
* @returns the new resolved config view.
|
|
88
|
+
* @throws when the settings service is unavailable.
|
|
89
|
+
*/
|
|
90
|
+
export async function handleSet(body, settings, bridge) {
|
|
91
|
+
const patch = extractPatch(body);
|
|
92
|
+
if (Object.keys(patch).length === 0) {
|
|
93
|
+
return { config: resolveConfig(bridge.source()) };
|
|
94
|
+
}
|
|
95
|
+
if (settings === undefined) {
|
|
96
|
+
throw new Error('interpreters: settings service is unavailable — configuration cannot be written');
|
|
97
|
+
}
|
|
98
|
+
await settings.update(SETTINGS_NAMESPACE, patch);
|
|
99
|
+
return { config: resolveConfig(bridge.source()) };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Extract and validate the patch from the request body.
|
|
103
|
+
*
|
|
104
|
+
* JSON wire boundary: null = "delete" (filtered), undefined never crosses
|
|
105
|
+
* JSON. Unknown keys are dropped (the settings service is non-strict and
|
|
106
|
+
* would otherwise store them). Light type guards constrain paths to
|
|
107
|
+
* strings and timeout to a finite number.
|
|
108
|
+
* @param body - the parsed JSON body.
|
|
109
|
+
* @returns the normalized patch (only known, well-typed keys).
|
|
110
|
+
*/
|
|
111
|
+
export function extractPatch(body) {
|
|
112
|
+
if (!isObject(body))
|
|
113
|
+
return {};
|
|
114
|
+
const raw = Reflect.get(body, 'patch');
|
|
115
|
+
if (!isObject(raw))
|
|
116
|
+
return {};
|
|
117
|
+
const normalized = {};
|
|
118
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
119
|
+
if (!ALLOWED_KEYS.has(key))
|
|
120
|
+
continue;
|
|
121
|
+
if (value === null || value === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
if (key === 'timeoutMs') {
|
|
124
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
if (typeof value !== 'string')
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
normalized[key] = value;
|
|
132
|
+
}
|
|
133
|
+
return normalized;
|
|
134
|
+
}
|
|
135
|
+
/** Read and parse a JSON body from a node:http request. */
|
|
136
|
+
async function readJsonBody(req) {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
for await (const chunk of req) {
|
|
139
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
140
|
+
}
|
|
141
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
142
|
+
if (text === '')
|
|
143
|
+
return {};
|
|
144
|
+
return JSON.parse(text);
|
|
145
|
+
}
|
|
146
|
+
/** Write a JSON response envelope. */
|
|
147
|
+
function writeJson(res, status, body) {
|
|
148
|
+
const json = JSON.stringify(body);
|
|
149
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
150
|
+
res.end(json);
|
|
151
|
+
}
|
|
152
|
+
/** Build a success envelope. */
|
|
153
|
+
function envelopeOk(value) {
|
|
154
|
+
return { ok: true, value };
|
|
155
|
+
}
|
|
156
|
+
/** Build an error envelope. */
|
|
157
|
+
function envelopeError(code, message) {
|
|
158
|
+
return { ok: false, error: { code, message } };
|
|
159
|
+
}
|
|
160
|
+
/** Narrow unknown to a non-null object. */
|
|
161
|
+
function isObject(value) {
|
|
162
|
+
return typeof value === 'object' && value !== null;
|
|
163
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — dsh-interpreters host plugin entry.
|
|
3
|
+
*
|
|
4
|
+
* Registers two model-facing tools (`run_python`, `run_node`) whose
|
|
5
|
+
* descriptions embed the configured interpreter paths. The paths persist
|
|
6
|
+
* through the settings seam under the `interpreters` namespace in
|
|
7
|
+
* `$DSH_HOME/settings.yaml`; runtime edits dispose and re-register the tools
|
|
8
|
+
* so the model immediately sees the updated path. The browser reaches the
|
|
9
|
+
* same namespace through a self-hosted `/interpreters/api` HTTP route
|
|
10
|
+
* (the DSH settings RPC domain only serves allowlisted namespaces to
|
|
11
|
+
* configuration clients, so this plugin exposes its own route through
|
|
12
|
+
* `ctx.webServer.register`, bypassing the wire-layer allowlist by calling
|
|
13
|
+
* the settings seam in-process).
|
|
14
|
+
*
|
|
15
|
+
* Architecture:
|
|
16
|
+
* - `installInterpretersSettings` registers the namespace and exposes a
|
|
17
|
+
* `source()` thunk + `onChange()` subscription.
|
|
18
|
+
* - `registerHttpGateway` claims `/interpreters/api/get|set` and
|
|
19
|
+
* reads/writes through the bridge + `ctx.settings` in-process.
|
|
20
|
+
* - The tool registration is re-run on every `bridge.onChange` notification
|
|
21
|
+
* so the model-visible description tracks the live interpreter path.
|
|
22
|
+
* - Headless assemblies without a settings provider fall back to the
|
|
23
|
+
* composition config (no persistence, no live reload, the `set`
|
|
24
|
+
* endpoint returns a clear "settings service unavailable" error).
|
|
25
|
+
*
|
|
26
|
+
* @module @huanlin/dsh-plugin-interpreters
|
|
27
|
+
*/
|
|
28
|
+
import { resolveConfig } from './config.js';
|
|
29
|
+
import { registerHttpGateway } from './gateway.js';
|
|
30
|
+
import { installInterpretersSettings } from './settings.js';
|
|
31
|
+
import { registerTools } from './tools.js';
|
|
32
|
+
export { Config, resolveConfig } from './config.js';
|
|
33
|
+
export { registerHttpGateway } from './gateway.js';
|
|
34
|
+
export { SETTINGS_NAMESPACE } from './settings.js';
|
|
35
|
+
export const name = 'dsh-interpreters';
|
|
36
|
+
export const inject = ['tools', 'webServer'];
|
|
37
|
+
/**
|
|
38
|
+
* Plugin body: register tools with the composition config, then swap to
|
|
39
|
+
* settings-resolved config when the settings service mounts, and expose the
|
|
40
|
+
* config through a `/interpreters/api/get|set` HTTP route.
|
|
41
|
+
* @param ctx - host context carrying `tools` and `webServer`.
|
|
42
|
+
* @param config - resolved composition config (seed).
|
|
43
|
+
*/
|
|
44
|
+
export function apply(ctx, config = {}) {
|
|
45
|
+
ctx.logger('dsh-interpreters').info('apply() called, config=', JSON.stringify(config));
|
|
46
|
+
const bridge = installInterpretersSettings(ctx, config);
|
|
47
|
+
let disposeTools = registerTools(ctx, resolveConfig(bridge.source()));
|
|
48
|
+
// Live re-register on every committed settings change so the model-visible
|
|
49
|
+
// tool description tracks the live interpreter path.
|
|
50
|
+
bridge.onChange(() => {
|
|
51
|
+
disposeTools?.();
|
|
52
|
+
disposeTools = registerTools(ctx, resolveConfig(bridge.source()));
|
|
53
|
+
});
|
|
54
|
+
// Register the HTTP gateway; the /interpreters/api route claims get/set.
|
|
55
|
+
registerHttpGateway(ctx, bridge);
|
|
56
|
+
ctx.logger('dsh-interpreters').info('http gateway registered at /interpreters/api');
|
|
57
|
+
ctx.effect(() => () => { disposeTools?.(); }, 'dsh-interpreters: cleanup');
|
|
58
|
+
}
|
package/lib/runner.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner.ts — subprocess execution for `run_python` / `run_node` tools.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the interpreter with `-` (read code from stdin), writes the code
|
|
5
|
+
* to stdin, and collects stdout/stderr with a 1 MB cap per stream.
|
|
6
|
+
* Honours `AbortSignal` and a timeout — both kill the process and report
|
|
7
|
+
* the outcome in the canonical result (C5: non-ideal states are values,
|
|
8
|
+
* not thrown errors).
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-interpreters/runner
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
/** Maximum captured bytes per stream (stdout / stderr). */
|
|
14
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
15
|
+
/**
|
|
16
|
+
* Execute `code` by piping it into `executable -` (stdin).
|
|
17
|
+
*
|
|
18
|
+
* @param executable - interpreter path (e.g. `python`, `node`, or an absolute path).
|
|
19
|
+
* @param code - source code to pipe via stdin.
|
|
20
|
+
* @param cwd - optional working directory.
|
|
21
|
+
* @param timeoutMs - wall-clock budget; the process is killed with SIGKILL on expiry.
|
|
22
|
+
* @param signal - caller-owned abort signal; aborting kills the process.
|
|
23
|
+
* @returns a {@link RunResult} describing the outcome.
|
|
24
|
+
*/
|
|
25
|
+
export function runCode(executable, code, cwd, timeoutMs, signal) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
if (signal.aborted) {
|
|
29
|
+
resolve({ ok: false, exit_code: -1, stdout: '', stderr: '', duration_ms: 0, timed_out: false, cancelled: true });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let child;
|
|
33
|
+
try {
|
|
34
|
+
child = spawn(executable, ['-'], { cwd, windowsHide: true });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
resolve({
|
|
38
|
+
ok: false,
|
|
39
|
+
exit_code: -1,
|
|
40
|
+
stdout: '',
|
|
41
|
+
stderr: `Failed to spawn "${executable}": ${String(error)}`,
|
|
42
|
+
duration_ms: Date.now() - start,
|
|
43
|
+
timed_out: false,
|
|
44
|
+
cancelled: false,
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let stdout = '';
|
|
49
|
+
let stderr = '';
|
|
50
|
+
let stdoutCapped = false;
|
|
51
|
+
let stderrCapped = false;
|
|
52
|
+
let timedOut = false;
|
|
53
|
+
const append = (buf, target) => {
|
|
54
|
+
const str = buf.toString('utf8');
|
|
55
|
+
if (target === 'stdout') {
|
|
56
|
+
if (stdout.length + str.length > MAX_OUTPUT_BYTES && !stdoutCapped) {
|
|
57
|
+
stdout += str.slice(0, MAX_OUTPUT_BYTES - stdout.length);
|
|
58
|
+
stdoutCapped = true;
|
|
59
|
+
}
|
|
60
|
+
else if (!stdoutCapped) {
|
|
61
|
+
stdout += str;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
if (stderr.length + str.length > MAX_OUTPUT_BYTES && !stderrCapped) {
|
|
66
|
+
stderr += str.slice(0, MAX_OUTPUT_BYTES - stderr.length);
|
|
67
|
+
stderrCapped = true;
|
|
68
|
+
}
|
|
69
|
+
else if (!stderrCapped) {
|
|
70
|
+
stderr += str;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
child.stdout?.on('data', (d) => append(d, 'stdout'));
|
|
75
|
+
child.stderr?.on('data', (d) => append(d, 'stderr'));
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
timedOut = true;
|
|
78
|
+
child.kill('SIGKILL');
|
|
79
|
+
}, timeoutMs);
|
|
80
|
+
const onAbort = () => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
child.kill('SIGKILL');
|
|
83
|
+
};
|
|
84
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
85
|
+
const finish = (exitCode) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
signal.removeEventListener('abort', onAbort);
|
|
88
|
+
if (stdoutCapped)
|
|
89
|
+
stdout += '\n[stdout truncated at 1 MB]';
|
|
90
|
+
if (stderrCapped)
|
|
91
|
+
stderr += '\n[stderr truncated at 1 MB]';
|
|
92
|
+
resolve({
|
|
93
|
+
ok: exitCode === 0 && !timedOut && !signal.aborted,
|
|
94
|
+
exit_code: exitCode ?? -1,
|
|
95
|
+
stdout,
|
|
96
|
+
stderr,
|
|
97
|
+
duration_ms: Date.now() - start,
|
|
98
|
+
timed_out: timedOut,
|
|
99
|
+
cancelled: signal.aborted,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
child.on('error', (error) => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
signal.removeEventListener('abort', onAbort);
|
|
105
|
+
resolve({
|
|
106
|
+
ok: false,
|
|
107
|
+
exit_code: -1,
|
|
108
|
+
stdout,
|
|
109
|
+
stderr: stderr + (stderr !== '' ? '\n' : '') + String(error),
|
|
110
|
+
duration_ms: Date.now() - start,
|
|
111
|
+
timed_out: false,
|
|
112
|
+
cancelled: signal.aborted,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
child.on('close', (code) => finish(code));
|
|
116
|
+
child.stdin?.on('error', () => { });
|
|
117
|
+
child.stdin?.write(code, 'utf8');
|
|
118
|
+
child.stdin?.end();
|
|
119
|
+
});
|
|
120
|
+
}
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings.ts — host-side bridge between the `interpreters` settings namespace
|
|
3
|
+
* and the plugin's other halves (tool registration + RPC gateway).
|
|
4
|
+
*
|
|
5
|
+
* The composition `Config` (cordis.patch.yml) is the first-boot seed; once the
|
|
6
|
+
* `ctx.settings` service mounts, the user-editable layer takes over and live
|
|
7
|
+
* re-registration follows every committed change. Headless assemblies without
|
|
8
|
+
* a settings provider fall back to the composition config (no persistence, no
|
|
9
|
+
* live reload).
|
|
10
|
+
*
|
|
11
|
+
* The bridge pattern mirrors `dsh-advisor/src/settings.ts`: a `source()` thunk
|
|
12
|
+
* the gateway reads in-process, plus an `onChange()` subscription the host
|
|
13
|
+
* entry uses to re-register the tools. This avoids any wire-layer allowlist
|
|
14
|
+
* (the DSH settings RPC domain only serves a fixed namespace set to browser
|
|
15
|
+
* configuration clients; the gateway bypasses it through `/api`).
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-interpreters/settings
|
|
18
|
+
*/
|
|
19
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
20
|
+
import { Config } from './config.js';
|
|
21
|
+
/** Settings namespace under which interpreter paths persist. */
|
|
22
|
+
export const SETTINGS_NAMESPACE = settingsNamespace('interpreters');
|
|
23
|
+
/**
|
|
24
|
+
* Mirror of the dsh-settings internal `isUnloading` guard. The cordis const
|
|
25
|
+
* enum for fiber state is erased at compile time, so the literal states are
|
|
26
|
+
* matched numerically: 4 = DISPOSED, 5 = UNLOADING.
|
|
27
|
+
*/
|
|
28
|
+
function isUnloading(ctx) {
|
|
29
|
+
const state = ctx.fiber?.state;
|
|
30
|
+
return state === 4 || state === 5;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Install the `interpreters` settings namespace and return the bridge.
|
|
34
|
+
*
|
|
35
|
+
* The settings service is reached through `ctx.inject(['settings'], ...)` so a
|
|
36
|
+
* composition without a settings provider still loads the plugin (entry-source
|
|
37
|
+
* fallback, no persistence). Multi-fiber dedupe is handled by catching the
|
|
38
|
+
* `"already registered"` rejection — host composition may mount several
|
|
39
|
+
* concurrent fibers of this plugin, and only the first registration owns the
|
|
40
|
+
* namespace.
|
|
41
|
+
* @param ctx - host context.
|
|
42
|
+
* @param entry - composition-layer config (cordis.patch.yml seed).
|
|
43
|
+
* @returns the bridge the gateway and tool re-registration consume.
|
|
44
|
+
*/
|
|
45
|
+
export function installInterpretersSettings(ctx, entry) {
|
|
46
|
+
const listeners = new Set();
|
|
47
|
+
let source = () => entry;
|
|
48
|
+
const notify = () => {
|
|
49
|
+
for (const listener of [...listeners])
|
|
50
|
+
listener();
|
|
51
|
+
};
|
|
52
|
+
ctx.inject(['settings'], (sctx) => {
|
|
53
|
+
let scope;
|
|
54
|
+
try {
|
|
55
|
+
scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: entry });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// Multi-fiber dedupe: the first registration owns the namespace; later
|
|
59
|
+
// fibers stay on the entry source and emit no notifications of their own.
|
|
60
|
+
if (!(error instanceof Error) || !error.message.includes('already registered'))
|
|
61
|
+
throw error;
|
|
62
|
+
ctx.logger('dsh-interpreters').debug('settings namespace already registered — entry-source fallback');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
source = () => scope.get();
|
|
66
|
+
sctx.effect(() => () => {
|
|
67
|
+
if (isUnloading(ctx))
|
|
68
|
+
return;
|
|
69
|
+
source = () => entry;
|
|
70
|
+
notify();
|
|
71
|
+
});
|
|
72
|
+
notify();
|
|
73
|
+
scope.watch(() => {
|
|
74
|
+
if (isUnloading(ctx))
|
|
75
|
+
return;
|
|
76
|
+
notify();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
source: () => source(),
|
|
81
|
+
onChange: (cb) => { listeners.add(cb); },
|
|
82
|
+
};
|
|
83
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.ts — `run_python` and `run_node` model-facing tools.
|
|
3
|
+
*
|
|
4
|
+
* Conventions (per plugin-development-guide.md §3):
|
|
5
|
+
* C4 — execute returns a canonical JSON value; render is a separate pure projection.
|
|
6
|
+
* C5 — timeout and cancellation are non-ideal business outcomes, represented in
|
|
7
|
+
* the value (timed_out / cancelled) rather than thrown.
|
|
8
|
+
* C6 — exec.signal is forwarded to the subprocess via runCode().
|
|
9
|
+
* C10 — no UI-specific formats in the canonical value.
|
|
10
|
+
*
|
|
11
|
+
* The tool `description` is computed from the resolved config at registration
|
|
12
|
+
* time so the model sees the interpreter path. Settings changes dispose the
|
|
13
|
+
* old registration and re-register with the fresh description (host index.ts).
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-interpreters/tools
|
|
16
|
+
*/
|
|
17
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
18
|
+
import { runCode } from './runner.js';
|
|
19
|
+
/**
|
|
20
|
+
* Build the model-visible description for `run_python`, embedding the
|
|
21
|
+
* configured interpreter path so the model knows exactly which executable
|
|
22
|
+
* will be invoked.
|
|
23
|
+
*/
|
|
24
|
+
export function buildPythonDescription(cfg) {
|
|
25
|
+
return 'Execute Python code and return stdout, stderr, and exit code. '
|
|
26
|
+
+ 'Code is passed via stdin (`' + cfg.pythonPath + ' -`), so there is no '
|
|
27
|
+
+ 'command-line length limit. '
|
|
28
|
+
+ 'The Python interpreter is located at: ' + cfg.pythonPath + '\n'
|
|
29
|
+
+ 'Use the optional `cwd` parameter to set the working directory.';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the model-visible description for `run_node`, embedding the
|
|
33
|
+
* configured interpreter path.
|
|
34
|
+
*/
|
|
35
|
+
export function buildNodeDescription(cfg) {
|
|
36
|
+
return 'Execute Node.js code and return stdout, stderr, and exit code. '
|
|
37
|
+
+ 'Code is passed via stdin (`' + cfg.nodePath + ' -`), so there is no '
|
|
38
|
+
+ 'command-line length limit. '
|
|
39
|
+
+ 'The Node.js interpreter is located at: ' + cfg.nodePath + '\n'
|
|
40
|
+
+ 'Use the optional `cwd` parameter to set the working directory.';
|
|
41
|
+
}
|
|
42
|
+
function textRender(fn) {
|
|
43
|
+
return (_args, value) => [{ type: 'text', text: fn(value) }];
|
|
44
|
+
}
|
|
45
|
+
export function renderRunCodeOutput(value) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
lines.push(`Exit code: ${value.exit_code} (${value.duration_ms}ms)`);
|
|
48
|
+
if (value.timed_out)
|
|
49
|
+
lines.push('Process was killed after exceeding the timeout.');
|
|
50
|
+
if (value.cancelled)
|
|
51
|
+
lines.push('Process was cancelled by an abort signal.');
|
|
52
|
+
if (value.stdout)
|
|
53
|
+
lines.push(`--- stdout ---\n${value.stdout}`);
|
|
54
|
+
if (value.stderr)
|
|
55
|
+
lines.push(`--- stderr ---\n${value.stderr}`);
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
const parametersSchema = {
|
|
59
|
+
code: { type: 'string', required: true, description: 'The code to execute.' },
|
|
60
|
+
cwd: { type: 'string', description: 'Optional working directory for the process.' },
|
|
61
|
+
};
|
|
62
|
+
const outputSchema = {
|
|
63
|
+
type: 'object',
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
properties: {
|
|
66
|
+
ok: { type: 'boolean', required: true, description: 'True if the process exited with code 0.' },
|
|
67
|
+
exit_code: { type: 'integer', required: true, description: 'Process exit code (-1 if the process failed to start).' },
|
|
68
|
+
stdout: { type: 'string', required: true, description: 'Captured stdout output.' },
|
|
69
|
+
stderr: { type: 'string', required: true, description: 'Captured stderr output.' },
|
|
70
|
+
duration_ms: { type: 'integer', required: true, description: 'Wall-clock execution time in milliseconds.' },
|
|
71
|
+
timed_out: { type: 'boolean', required: true, description: 'True if the process was killed due to timeout.' },
|
|
72
|
+
cancelled: { type: 'boolean', required: true, description: 'True if the process was killed due to an abort signal.' },
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Register `run_python` and `run_node` tools with descriptions that embed
|
|
77
|
+
* the interpreter paths from `cfg`. Returns a disposer that unregisters
|
|
78
|
+
* both tools — call it before re-registering with a fresh config.
|
|
79
|
+
*/
|
|
80
|
+
export function registerTools(ctx, cfg) {
|
|
81
|
+
const disposers = [];
|
|
82
|
+
disposers.push(ctx.tools.register(defineTool({
|
|
83
|
+
name: 'run_python',
|
|
84
|
+
description: buildPythonDescription(cfg),
|
|
85
|
+
parameters: parametersSchema,
|
|
86
|
+
output: {
|
|
87
|
+
schema: outputSchema,
|
|
88
|
+
render: textRender(renderRunCodeOutput),
|
|
89
|
+
},
|
|
90
|
+
execute: async (args, exec) => {
|
|
91
|
+
const a = args;
|
|
92
|
+
return runCode(cfg.pythonPath, a.code, a.cwd, cfg.timeoutMs, exec.signal);
|
|
93
|
+
},
|
|
94
|
+
})));
|
|
95
|
+
disposers.push(ctx.tools.register(defineTool({
|
|
96
|
+
name: 'run_node',
|
|
97
|
+
description: buildNodeDescription(cfg),
|
|
98
|
+
parameters: parametersSchema,
|
|
99
|
+
output: {
|
|
100
|
+
schema: outputSchema,
|
|
101
|
+
render: textRender(renderRunCodeOutput),
|
|
102
|
+
},
|
|
103
|
+
execute: async (args, exec) => {
|
|
104
|
+
const a = args;
|
|
105
|
+
return runCode(cfg.nodePath, a.code, a.cwd, cfg.timeoutMs, exec.signal);
|
|
106
|
+
},
|
|
107
|
+
})));
|
|
108
|
+
return () => { for (const dispose of disposers)
|
|
109
|
+
dispose(); };
|
|
110
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InterpretersCard — the `settings.plugin.item` card for the interpreters
|
|
3
|
+
* configuration.
|
|
4
|
+
*
|
|
5
|
+
* Self-drawn chrome replicating the upstream `PluginCard` contract: the
|
|
6
|
+
* upstream client value face exports no reusable card component, so this
|
|
7
|
+
* card draws its own collapsible `<li>` with the same header button (name
|
|
8
|
+
* over description, dirty pill, rotating chevron, aria) and divided body
|
|
9
|
+
* (readOnly notice, form fields, footer with failed/saved message +
|
|
10
|
+
* Discard/Save). Three fields (pythonPath, nodePath, timeoutMs) are staged
|
|
11
|
+
* through the card's controller; save commits them through the
|
|
12
|
+
* `/api/interpreters/set` gateway channel.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-interpreters/client/InterpretersCard
|
|
15
|
+
*/
|
|
16
|
+
import { type ReactNode } from 'react';
|
|
17
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
18
|
+
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react';
|
|
19
|
+
import { InterpretersCardController, type InterpretersCardState } from './store.ts';
|
|
20
|
+
import type { InterpretersKey } from './locales.ts';
|
|
21
|
+
/** Injected dependencies of {@link InterpretersCard} (slot `inject`). */
|
|
22
|
+
export interface InterpretersCardInjected {
|
|
23
|
+
/** The card controller (loaded on mount, refreshed on pushed invalidations). */
|
|
24
|
+
controller: InterpretersCardController;
|
|
25
|
+
/** uSES subscription hook bound to the store. */
|
|
26
|
+
useSnapshot: SnapshotSelectorHook<InterpretersCardState>;
|
|
27
|
+
}
|
|
28
|
+
/** Props the renderer binds for the card. */
|
|
29
|
+
export type InterpretersCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'interpreters'> & InjectFace<InterpretersCardInjected>;
|
|
30
|
+
/**
|
|
31
|
+
* Render the interpreters card inside the plugin-config section, replicating
|
|
32
|
+
* the upstream PluginCard chrome.
|
|
33
|
+
* @param props - slot-delivered injected dependencies and the synthesized t seat.
|
|
34
|
+
* @returns the card.
|
|
35
|
+
*/
|
|
36
|
+
export declare function InterpretersCard(props: InterpretersCardProps): ReactNode;
|
|
37
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
38
|
+
interface LocaleNamespaceMap {
|
|
39
|
+
/** The interpreters card copy. */
|
|
40
|
+
'interpreters': InterpretersKey;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* InterpretersCard — the `settings.plugin.item` card for the interpreters
|
|
4
|
+
* configuration.
|
|
5
|
+
*
|
|
6
|
+
* Self-drawn chrome replicating the upstream `PluginCard` contract: the
|
|
7
|
+
* upstream client value face exports no reusable card component, so this
|
|
8
|
+
* card draws its own collapsible `<li>` with the same header button (name
|
|
9
|
+
* over description, dirty pill, rotating chevron, aria) and divided body
|
|
10
|
+
* (readOnly notice, form fields, footer with failed/saved message +
|
|
11
|
+
* Discard/Save). Three fields (pythonPath, nodePath, timeoutMs) are staged
|
|
12
|
+
* through the card's controller; save commits them through the
|
|
13
|
+
* `/api/interpreters/set` gateway channel.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-interpreters/client/InterpretersCard
|
|
16
|
+
*/
|
|
17
|
+
import { useState } from 'react';
|
|
18
|
+
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
19
|
+
import { formatFieldNumber, formatFieldText, } from "./store.js";
|
|
20
|
+
import styles from './InterpretersCard.module.css';
|
|
21
|
+
/**
|
|
22
|
+
* Render the interpreters card inside the plugin-config section, replicating
|
|
23
|
+
* the upstream PluginCard chrome.
|
|
24
|
+
* @param props - slot-delivered injected dependencies and the synthesized t seat.
|
|
25
|
+
* @returns the card.
|
|
26
|
+
*/
|
|
27
|
+
export function InterpretersCard(props) {
|
|
28
|
+
const { controller, useSnapshot, t } = props;
|
|
29
|
+
const state = useSnapshot(snapshot => snapshot);
|
|
30
|
+
// Load-on-mount: the plugin-config page mounts the card lazily when the
|
|
31
|
+
// user opens the settings panel, so the first mount triggers the first
|
|
32
|
+
// gateway load.
|
|
33
|
+
if (state.status === 'idle')
|
|
34
|
+
void controller.load();
|
|
35
|
+
// Disclosure is card-local USER state (upstream rationale): the healthy
|
|
36
|
+
// card starts collapsed and opens on the header click only. The degraded
|
|
37
|
+
// (unavailable) card renders its notice body ALWAYS visible, so `open` is
|
|
38
|
+
// DERIVED from the current snapshot.
|
|
39
|
+
const [userOpen, setUserOpen] = useState(false);
|
|
40
|
+
const degraded = state.status === 'ready' && !state.available;
|
|
41
|
+
const open = userOpen || degraded;
|
|
42
|
+
const title = t('title');
|
|
43
|
+
const header = (_jsxs("button", { type: "button", className: styles.header, "aria-expanded": open, "aria-label": `${t(open ? 'collapse' : 'expand')}: ${title}`,
|
|
44
|
+
// While degraded the derived open is forced true, so the click must be
|
|
45
|
+
// a no-op — toggling userOpen would silently latch it and pre-open the
|
|
46
|
+
// recovered form.
|
|
47
|
+
onClick: () => { if (!degraded)
|
|
48
|
+
setUserOpen(!userOpen); }, children: [_jsxs("span", { className: styles.headText, children: [_jsx("span", { className: styles.name, children: title }), _jsx("span", { className: styles.description, children: t('intro') })] }), state.dirty ? _jsx("span", { className: styles.pending, children: t('unsaved') }) : null, _jsx(IconChevronDownOutline14, { className: open ? `${styles.chevron} ${styles.chevronOpen}` : styles.chevron })] }));
|
|
49
|
+
let body;
|
|
50
|
+
if (degraded) {
|
|
51
|
+
// The gateway channel is down or the namespace is not served to this
|
|
52
|
+
// client — render the explicit notice and never offer Save.
|
|
53
|
+
body = (_jsxs("div", { className: styles.body, children: [_jsx("p", { className: styles.notice, role: "status", children: t('namespaceUnavailable') }), _jsx("div", { className: styles.footer, children: _jsx("button", { type: "button", className: styles.discard, onClick: () => { void controller.load(); }, children: t('retry') }) })] }));
|
|
54
|
+
}
|
|
55
|
+
else if (state.status === 'ready') {
|
|
56
|
+
const { draft, writable, applyState } = state;
|
|
57
|
+
const saving = applyState.kind === 'saving';
|
|
58
|
+
const busy = !writable || saving;
|
|
59
|
+
const saveDisabled = !state.dirty || saving || !writable;
|
|
60
|
+
const discardDisabled = !state.dirty || saving;
|
|
61
|
+
const errorText = applyState.kind === 'error' ? applyState.message : undefined;
|
|
62
|
+
body = (_jsxs("div", { className: styles.body, children: [!writable ? _jsx("p", { className: styles.readOnly, role: "status", children: t('readOnly') }) : null, applyState.kind === 'saved' ? _jsx("p", { className: styles.savedNotice, role: "status", children: t('save') }) : null, _jsxs("div", { className: styles.form, children: [_jsx(Field, { id: "plugin-config-interpreters-python", label: t('pythonPath'), hint: t('pythonHelp'), text: formatFieldText(draft.pythonPath), disabled: busy, onEdit: (text) => { controller.edit('pythonPath', text); } }), _jsx(Field, { id: "plugin-config-interpreters-node", label: t('nodePath'), hint: t('nodeHelp'), text: formatFieldText(draft.nodePath), disabled: busy, onEdit: (text) => { controller.edit('nodePath', text); } }), _jsx(Field, { id: "plugin-config-interpreters-timeout", label: t('timeoutMs'), hint: t('timeoutHelp'), text: formatFieldNumber(draft.timeoutMs), numeric: true, disabled: busy, onEdit: (text) => { controller.edit('timeoutMs', text); } })] }), _jsxs("div", { className: styles.footer, children: [errorText === undefined ? null : _jsx("p", { className: styles.failed, role: "status", children: errorText }), _jsx("button", { type: "button", className: styles.discard, disabled: discardDisabled, onClick: () => { controller.discard(); }, children: t('discard') }), _jsx("button", { type: "button", className: styles.save, disabled: saveDisabled, onClick: () => { controller.save(); }, children: t(saving ? 'saving' : 'save') })] })] }));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Loading (or the idle→loading transition): the header alone — an open
|
|
66
|
+
// card shows an empty body.
|
|
67
|
+
body = _jsx("div", { className: styles.body });
|
|
68
|
+
}
|
|
69
|
+
return (_jsxs("li", { className: open ? `${styles.card} ${styles.cardOpen}` : styles.card, children: [header, open ? body : null] }));
|
|
70
|
+
}
|
|
71
|
+
/** One staged field control (text or numeric). */
|
|
72
|
+
function Field(props) {
|
|
73
|
+
return (_jsxs("div", { className: styles.field, children: [_jsx("label", { className: styles.fieldLabel, htmlFor: props.id, children: props.label }), _jsx("input", { id: props.id, className: styles.input, type: props.numeric ? 'number' : 'text', ...props.numeric ? { inputMode: 'numeric' } : {}, value: props.text, disabled: props.disabled, onChange: (event) => { props.onEdit(event.target.value); } }), _jsx("p", { className: styles.hint, children: props.hint })] }));
|
|
74
|
+
}
|