@elinpf/dsh-ops-panel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ title: "@elinpf/dsh-ops-panel"
2
+ description:
3
+ zh: "运维插件的面板缝 — 会话作用域对话框的公共机制"
4
+ en: "The panel seam for ops plugins — session-scoped overlay dialogs triggered by slash commands"
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @elinpf/dsh-ops-panel
2
+
3
+ The panel seam for DeepSeek Harness ops plugins — one shared mechanism for session-scoped overlay dialogs triggered by slash commands (ADR-0004).
4
+
5
+ ## What it does
6
+
7
+ Business packages should not each invent their own dialog shell, keyboard handling, and command dispatch. ops-panel splits that concern in two:
8
+
9
+ - **Client half** (`ops-panel-client`): provides the `opsPanels` registry service. Consumers register one panel definition per command — `registerPanel({ command, title, component })`. The shell renders the open panel as a centered modal over a backdrop (Escape / backdrop click closes), injected into the `conversation.input.overlay` slot. A single `command/executed` listener dispatches to the right panel by command name, so N panels cost one listener, not N.
10
+ - **Host helper** (`registerPanelCommand(ctx, { name, description })`): registers the slash command itself — a no-op success handler whose only job is to exist in the session's command directory so the UI lets the user submit it and fires `command/executed`. Registers agent-scoped when called from the preset plane; fails loud when the `commands` service is absent.
11
+
12
+ A panel component receives `{ sessionId, close }` and owns everything inside the card — data fetching, polling, actions.
13
+
14
+ ## Why a service, not a library
15
+
16
+ Bundling this code per consumer would give every consumer its own registry, its own `command/executed` listener, and its own overlay shell — and only the last-registered shell would win. As a cordis service there is exactly one registry and one shell per page, and panels can be registered from any plugin.
17
+
18
+ ## Installation
19
+
20
+ Add to the app dependencies and reference in `cordis.patch.yml` (host plane):
21
+
22
+ ```yaml
23
+ - id: ops-panel
24
+ name: '@elinpf/dsh-ops-panel'
25
+ ```
26
+
27
+ Consumers register from their own client halves:
28
+
29
+ ```ts
30
+ ctx.inject(['opsPanels'], (pctx) => {
31
+ pctx.effect(() => pctx.opsPanels.registerPanel({
32
+ command: 'access',
33
+ title: '访问授权',
34
+ component: AccessPanel,
35
+ }))
36
+ })
37
+ ```
38
+
39
+ …and register the command from their preset-plane host half:
40
+
41
+ ```ts
42
+ ctx.inject(['commands'], (cctx) => {
43
+ registerPanelCommand(cctx, { name: 'access', description: '打开授权面板' })
44
+ })
45
+ ```
46
+
47
+ First consumer: the access panel (`ops-access-ui` + `ops-access/gate`, spec 0003).
48
+
49
+ ## Known Limitations
50
+
51
+ - One open panel at a time (opening another replaces the current one).
52
+ - The shell is deliberately minimal (title + close) — tab bars, sizing, and drag belong to a later iteration if a second consumer needs them.
53
+ - `command/executed` fires only for sessions whose preset mounts the command — panels are inherently preset-scoped.
package/README.zh.md ADDED
@@ -0,0 +1,31 @@
1
+ # @elinpf/dsh-ops-panel
2
+
3
+ DeepSeek Harness 运维插件的面板缝 — 会话作用域对话框的公共机制,由斜杠命令触发(ADR-0004)。
4
+
5
+ ## 它做什么
6
+
7
+ 业务包不应各自重造对话框外壳、键盘处理和命令分发。ops-panel 把这件事拆成两半:
8
+
9
+ - **client 半**(`ops-panel-client`):提供 `opsPanels` 注册表服务。消费方每个命令注册一条面板定义 — `registerPanel({ command, title, component })`。外壳把打开的面板渲染为遮罩上的居中卡片(Escape / 点遮罩关闭),注入 `conversation.input.overlay` 槽位。单一的 `command/executed` 监听器按命令名分发 — N 个面板只花一个监听器。
10
+ - **host 助手**(`registerPanelCommand(ctx, { name, description })`):注册斜杠命令本身 — 一个空操作的成功 handler,唯一职责是存在于会话命令目录里,让 UI 允许提交并触发 `command/executed`。从 preset 面调用时按 agent 作用域注册;`commands` 服务缺席时直接报错。
11
+
12
+ 面板组件收到 `{ sessionId, close }`,卡片内的一切(取数、轮询、动作)归它自己管。
13
+
14
+ ## 为什么是服务而不是库
15
+
16
+ 按消费方各自打包会得到 N 份注册表、N 个 `command/executed` 监听器、N 个遮罩外壳 — 而只有最后注册的外壳生效。作为 cordis 服务,每个页面只有一份注册表和一个外壳,任何插件都能注册面板。
17
+
18
+ ## 安装
19
+
20
+ 加入 app 依赖并在 `cordis.patch.yml` 引用(host 面):
21
+
22
+ ```yaml
23
+ - id: ops-panel
24
+ name: '@elinpf/dsh-ops-panel'
25
+ ```
26
+
27
+ ## 已知限制
28
+
29
+ - 同时只打开一个面板(打开另一个会替换当前的)。
30
+ - 外壳刻意极简(标题 + 关闭)——标签页、尺寸、拖拽留给有第二个消费者时再迭代。
31
+ - `command/executed` 只在挂了这个命令的会话里触发 — 面板天然是 preset 作用域的。
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: ops-panel
3
+ name: '@elinpf/dsh-ops-panel'
package/lib/client.js ADDED
@@ -0,0 +1,167 @@
1
+ window.__ModuleLoader__.load({ id: "@elinpf/dsh-ops-panel", factory: (require) => {
2
+ var module = { exports: {} }; var exports = module.exports;
3
+ "use strict";
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name2 in all)
10
+ __defProp(target, name2, { get: all[name2], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
21
+
22
+ // src/client.ts
23
+ var client_exports = {};
24
+ __export(client_exports, {
25
+ ShellView: () => ShellView,
26
+ apply: () => apply,
27
+ createPanelCore: () => createPanelCore,
28
+ inject: () => inject,
29
+ name: () => name
30
+ });
31
+ module.exports = __toCommonJS(client_exports);
32
+ var import_react = require("react");
33
+ var import_react2 = require("react");
34
+ var name = "ops-panel-client";
35
+ var inject = ["slots"];
36
+ function createPanelCore() {
37
+ const registry = /* @__PURE__ */ new Map();
38
+ const listeners = /* @__PURE__ */ new Set();
39
+ let snapshot = /* @__PURE__ */ new Map();
40
+ function emit() {
41
+ for (const fn of listeners) fn();
42
+ }
43
+ function open(sessionId, command) {
44
+ const def = registry.get(command);
45
+ if (!def) return false;
46
+ if (def.available && !def.available(sessionId)) return false;
47
+ const next = new Map(snapshot);
48
+ next.set(sessionId, command);
49
+ snapshot = next;
50
+ emit();
51
+ return true;
52
+ }
53
+ return {
54
+ registerPanel(def) {
55
+ if (registry.has(def.command)) {
56
+ throw new Error("ops-panel: a panel is already registered for command " + def.command);
57
+ }
58
+ registry.set(def.command, def);
59
+ return () => {
60
+ registry.delete(def.command);
61
+ };
62
+ },
63
+ get(command) {
64
+ return registry.get(command);
65
+ },
66
+ subscribe(fn) {
67
+ listeners.add(fn);
68
+ return () => {
69
+ listeners.delete(fn);
70
+ };
71
+ },
72
+ getSnapshot() {
73
+ return snapshot;
74
+ },
75
+ open,
76
+ close(sessionId) {
77
+ if (!snapshot.has(sessionId)) return;
78
+ const next = new Map(snapshot);
79
+ next.delete(sessionId);
80
+ snapshot = next;
81
+ emit();
82
+ },
83
+ handleCommandExecuted(sessionId, commandName) {
84
+ open(sessionId, commandName);
85
+ }
86
+ };
87
+ }
88
+ var CSS = [
89
+ ".ops-panel-backdrop { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.4); }",
90
+ ".ops-panel-card { box-sizing: border-box; width: min(560px, calc(100vw - 48px)); max-height: min(70vh, 640px); display: flex; flex-direction: column; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l1, #e5e7eb); background: var(--dsw-alias-bg-primary, #ffffff); color: var(--dsw-alias-text-primary, #1f2328); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); overflow: hidden; }",
91
+ ".ops-panel-header { flex: none; display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; font-size: 14px; font-weight: 600; border-bottom: 1px solid var(--dsw-alias-border-l1, #e5e7eb); }",
92
+ ".ops-panel-close { border: none; background: transparent; color: inherit; font-size: 16px; line-height: 1; padding: 4px 8px; cursor: pointer; border-radius: 6px; opacity: 0.6; }",
93
+ ".ops-panel-close:hover { opacity: 1; background: var(--dsw-alias-bg-secondary, #f6f8fa); }",
94
+ ".ops-panel-body { flex: 1 1 auto; overflow-y: auto; padding: 12px 16px; font-size: 13px; }"
95
+ ].join("\n");
96
+ var cssInjected = false;
97
+ function injectCSS() {
98
+ if (cssInjected || typeof document === "undefined") return;
99
+ cssInjected = true;
100
+ const el = document.createElement("style");
101
+ el.setAttribute("data-ops-panel", "");
102
+ el.textContent = CSS;
103
+ document.head.appendChild(el);
104
+ }
105
+ function ShellView({ sessionId }, core) {
106
+ const openMap = (0, import_react2.useSyncExternalStore)(core.subscribe, core.getSnapshot);
107
+ const sid = sessionId;
108
+ const command = sid ? openMap.get(sid) : void 0;
109
+ const def = command ? core.get(command) : void 0;
110
+ const open = Boolean(sid && def);
111
+ (0, import_react.useEffect)(() => {
112
+ if (!open || !sid) return;
113
+ const onKey = (e) => {
114
+ if (e.key === "Escape") core.close(sid);
115
+ };
116
+ document.addEventListener("keydown", onKey);
117
+ return () => document.removeEventListener("keydown", onKey);
118
+ }, [open, sid]);
119
+ if (!open || !sid || !def) return null;
120
+ const close = () => core.close(sid);
121
+ return (0, import_react.createElement)(
122
+ "div",
123
+ { className: "ops-panel-backdrop", onClick: close },
124
+ (0, import_react.createElement)(
125
+ "div",
126
+ { className: "ops-panel-card", onClick: (e) => e.stopPropagation() },
127
+ (0, import_react.createElement)(
128
+ "div",
129
+ { className: "ops-panel-header" },
130
+ (0, import_react.createElement)("span", null, def.title),
131
+ (0, import_react.createElement)("button", { className: "ops-panel-close", onClick: close, "aria-label": "\u5173\u95ED" }, "\xD7")
132
+ ),
133
+ (0, import_react.createElement)("div", { className: "ops-panel-body" }, (0, import_react.createElement)(def.component, { sessionId: sid, close }))
134
+ )
135
+ );
136
+ }
137
+ function apply(ctx) {
138
+ injectCSS();
139
+ const core = createPanelCore();
140
+ const service = {
141
+ registerPanel: (def) => core.registerPanel(def),
142
+ // The imperative open path (ADR-0004 §9): ambient affordances like the
143
+ // access-request badge pull the panel up without a typed command.
144
+ open: (sessionId, command) => core.open(sessionId, command),
145
+ close: (sessionId) => core.close(sessionId)
146
+ };
147
+ ctx.provide("opsPanels", service);
148
+ const events = ctx;
149
+ events.on("command/executed", (sessionId, commandName) => {
150
+ core.handleCommandExecuted(String(sessionId), commandName);
151
+ });
152
+ const slots = ctx.get("slots");
153
+ if (slots !== void 0) {
154
+ ctx.effect(
155
+ () => slots.inject(
156
+ "conversation.input.overlay",
157
+ () => slots.register(
158
+ { name: "conversation.input.overlay", id: "ops-panel", order: 5 },
159
+ (props) => ShellView(props, core)
160
+ )
161
+ )
162
+ );
163
+ }
164
+ }
165
+
166
+ return module.exports; } });
167
+ //# sourceMappingURL=client.js.map
package/lib/index.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Ops panel seam, node half (ADR-0004). The plugin row itself is an empty
3
+ * shell whose only job is client-bundle discovery (same pattern as
4
+ * ops-access-ui). The node-side value is the registerPanelCommand helper:
5
+ * a panel trigger is a host slash command whose handler is a deliberate
6
+ * no-op — the lifecycle pair (command/run, command/done) is the durable,
7
+ * log-only record, and the command/executed local browser event is what
8
+ * opens the panel on the client half.
9
+ *
10
+ * @module @elinpf/dsh-ops-panel
11
+ */
12
+ import z from '@deepseek-ai/schemastery';
13
+ import type { PanelCommandSpec } from './types.ts';
14
+ export type { OpsPanels, PanelCommandSpec, PanelComponent, PanelContentProps, PanelDefinition } from './types.ts';
15
+ export declare const name = "ops-panel";
16
+ export declare const inject: string[];
17
+ /** No options: the row is a discovery-only shell, the helper takes its spec per call. */
18
+ export declare const Config: z<Schemastery.ObjectS<{}>, Schemastery.ObjectT<{}>>;
19
+ /** Empty by design: the row exists so the web resolver finds lib/client.js. */
20
+ export declare function apply(_ctx: never): void;
21
+ /**
22
+ * The structural host this helper needs — deliberately NOT cordis's Context:
23
+ * consumers link their own cordis instance (pnpm hoisting yields two type
24
+ * graphs for the same version), and a structural parameter sidesteps the
25
+ * dual-instance type clash. Any cordis context satisfies this.
26
+ */
27
+ export interface PanelCommandHost {
28
+ get(name: string): unknown;
29
+ effect(fn: () => () => void): unknown;
30
+ }
31
+ /**
32
+ * Register the slash command that triggers a panel. The handler is a no-op
33
+ * success: all behavior lives on the client half, dispatched from the local
34
+ * command/executed event. The command registers agent-scoped when the caller
35
+ * sits under agent.ctx (preset plane), so a panel command only exists in the
36
+ * presets that mount the calling plugin.
37
+ *
38
+ * Misconfiguration fails loud at load: an invalid name or a missing commands
39
+ * service throws instead of silently registering nothing.
40
+ *
41
+ * @param ctx - the calling plugin context (preset plane scopes the command).
42
+ * @param spec - command name and menu description.
43
+ */
44
+ export declare function registerPanelCommand(ctx: PanelCommandHost, spec: PanelCommandSpec): void;
45
+ //# sourceMappingURL=index.d.ts.map
package/lib/index.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Ops panel seam, node half (ADR-0004). The plugin row itself is an empty
3
+ * shell whose only job is client-bundle discovery (same pattern as
4
+ * ops-access-ui). The node-side value is the registerPanelCommand helper:
5
+ * a panel trigger is a host slash command whose handler is a deliberate
6
+ * no-op — the lifecycle pair (command/run, command/done) is the durable,
7
+ * log-only record, and the command/executed local browser event is what
8
+ * opens the panel on the client half.
9
+ *
10
+ * @module @elinpf/dsh-ops-panel
11
+ */
12
+ import z from '@deepseek-ai/schemastery';
13
+ // ── Plugin identity ───────────────────────────────────────────────────────────
14
+ export const name = 'ops-panel';
15
+ export const inject = [];
16
+ // ── Config ───────────────────────────────────────────────────────────────────
17
+ /** No options: the row is a discovery-only shell, the helper takes its spec per call. */
18
+ export const Config = z.object({});
19
+ /** Empty by design: the row exists so the web resolver finds lib/client.js. */
20
+ export function apply(_ctx) { }
21
+ // ── registerPanelCommand ─────────────────────────────────────────────────────
22
+ /** Slash command names parse as lowercase letters, digits, underscore, dash. */
23
+ const COMMAND_NAME = /^[a-z0-9_-]+$/;
24
+ /**
25
+ * Register the slash command that triggers a panel. The handler is a no-op
26
+ * success: all behavior lives on the client half, dispatched from the local
27
+ * command/executed event. The command registers agent-scoped when the caller
28
+ * sits under agent.ctx (preset plane), so a panel command only exists in the
29
+ * presets that mount the calling plugin.
30
+ *
31
+ * Misconfiguration fails loud at load: an invalid name or a missing commands
32
+ * service throws instead of silently registering nothing.
33
+ *
34
+ * @param ctx - the calling plugin context (preset plane scopes the command).
35
+ * @param spec - command name and menu description.
36
+ */
37
+ export function registerPanelCommand(ctx, spec) {
38
+ if (!COMMAND_NAME.test(spec.name)) {
39
+ throw new Error('ops-panel: invalid panel command name ' + JSON.stringify(spec.name) + ' (lowercase letters, digits, underscore, dash only)');
40
+ }
41
+ const commands = ctx.get('commands');
42
+ if (!commands) {
43
+ throw new Error('ops-panel: the commands service is not composed — panel command ' + spec.name + ' cannot be registered in this deployment');
44
+ }
45
+ ctx.effect(() => commands.register({
46
+ name: spec.name,
47
+ description: spec.description,
48
+ handler: () => ({ kind: 'success', text: 'Opening panel: ' + spec.name })
49
+ }));
50
+ }
51
+ //# sourceMappingURL=index.js.map
package/lib/types.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Vocabulary types of the panel seam (ADR-0004). Types only — no runtime
3
+ * code. Both halves import from here: the node half for the command helper,
4
+ * the client half for the registry contract, consumers for both.
5
+ */
6
+ /**
7
+ * Props every panel content component receives from the shell. sessionId
8
+ * is the session the slash command ran in (panels are session-scoped);
9
+ * close dismisses the panel (e.g. after a successful submit).
10
+ */
11
+ export interface PanelContentProps {
12
+ readonly sessionId: string;
13
+ readonly close: () => void;
14
+ }
15
+ /**
16
+ * A panel content renderer, hosted inside the shell card. React-free by
17
+ * design: no repo package compiles against React types (client bundles are
18
+ * esbuild-only, excluded from tsc), so the return type stays structural —
19
+ * the shell hands the component to createElement, consumers return React
20
+ * elements.
21
+ */
22
+ export type PanelComponent = (props: PanelContentProps) => unknown;
23
+ /**
24
+ * One panel registration. The panel identity IS its command name: the
25
+ * node half registers a host slash command of the same name via
26
+ * registerPanelCommand, and the browser that executed it opens this panel.
27
+ */
28
+ export interface PanelDefinition {
29
+ /** Slash command name without the slash (lowercase letters, digits, underscore, dash). */
30
+ readonly command: string;
31
+ /** Title shown in the shell header bar. */
32
+ readonly title: string;
33
+ /** Content component rendered inside the shell card. */
34
+ readonly component: PanelComponent;
35
+ /**
36
+ * Capability filter evaluated when the command executes: return false to
37
+ * keep the panel closed for that session (e.g. non-ops presets).
38
+ */
39
+ readonly available?: (sessionId: string) => boolean;
40
+ }
41
+ /**
42
+ * The ctx.opsPanels client service face (plural key — a registry).
43
+ * Registration is an effect: the returned disposer removes the panel.
44
+ */
45
+ export interface OpsPanels {
46
+ /**
47
+ * Register one panel. Duplicate command names throw at registration.
48
+ * @param def - the panel identity and content.
49
+ * @returns disposer removing the panel.
50
+ */
51
+ registerPanel(def: PanelDefinition): () => void;
52
+ /**
53
+ * Open a panel without a slash command (ADR-0004 §9): the imperative
54
+ * path for ambient affordances — e.g. the access-request badge pulling
55
+ * up the approval deck while a request_access call parks.
56
+ * @param sessionId - the session whose overlay hosts the panel.
57
+ * @param command - the panel's command name.
58
+ * @returns false when nothing is registered for the command or the
59
+ * panel's capability filter declines this session.
60
+ */
61
+ open(sessionId: string, command: string): boolean;
62
+ /**
63
+ * Dismiss the session's open panel. No-op when none is open.
64
+ * @param sessionId - the session whose panel to close.
65
+ */
66
+ close(sessionId: string): void;
67
+ }
68
+ /** Spec for the node-side slash command that triggers a panel. */
69
+ export interface PanelCommandSpec {
70
+ /** Command name without the slash; must match the panel command field. */
71
+ readonly name: string;
72
+ /** Menu row description. */
73
+ readonly description: string;
74
+ }
75
+ //# sourceMappingURL=types.d.ts.map
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@elinpf/dsh-ops-panel",
3
+ "version": "0.1.0",
4
+ "description": "Panel seam for ops plugins — a slash-command-triggered, session-scoped dialog shell. Provides the ctx.opsPanels client registry (single command/executed dispatcher + overlay shell) and the node-side registerPanelCommand helper. The first consumer is the access-grant panel (ADR-0004).",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./client": {
14
+ "default": "./lib/client.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "lib/index.js",
20
+ "lib/client.js",
21
+ "lib/**/*.d.ts",
22
+ "cordis.patch.yml"
23
+ ],
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "platform": "web"
30
+ }
31
+ },
32
+ "dependencies": {
33
+ "@deepseek-ai/schemastery": "^3.18.1"
34
+ },
35
+ "peerDependencies": {
36
+ "@deepseek-ai/cordis": "^4.0.1",
37
+ "react": "^18.2.0"
38
+ },
39
+ "devDependencies": {
40
+ "@deepseek-ai/cordis": "4.0.1",
41
+ "esbuild": "^0.24.0",
42
+ "jsdom": "^30.0.1",
43
+ "react": "^18.2.0",
44
+ "react-dom": "^18.2.0",
45
+ "typescript": "^5.4.0",
46
+ "vitest": "^4.1.11"
47
+ },
48
+ "license": "MIT",
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc && node esbuild.config.mjs",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest run"
56
+ }
57
+ }