dsh-creator-mode-plus 0.3.8

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/src/index.js ADDED
@@ -0,0 +1,377 @@
1
+ import { installTakeoverFence, requestTakeover } from './takeover.js'
2
+ /** Creator Mode+ model tools backed by fixed dshx operations. */
3
+
4
+ import {
5
+ currentWebPort,
6
+ installCreatorRecovery,
7
+ runClaimedDshx,
8
+ runClientFailureDshx,
9
+ runDshx,
10
+ } from './runner.js'
11
+ import { healCreatorPlusPresets } from './preset-015.js'
12
+ import {
13
+ forgetCreatorClaim,
14
+ installCreatorSafetyGuard,
15
+ rememberCreatorClaim,
16
+ } from './safety.js'
17
+
18
+ export {
19
+ CREATOR_BRIDGE_VERSION,
20
+ CREATOR_MODEL_TOOLS,
21
+ DSHX_CONTRACT,
22
+ } from './compatibility.js'
23
+
24
+ export const name = 'dsh-creator-mode-plus'
25
+ export const inject = ['tools', 'webServer', 'connection', 'agents', 'sessions', 'userQuestions']
26
+
27
+ const CLIENT_FAILURE_PATH = '/dsh-creator-mode-plus/client-failure'
28
+ const MAX_CLIENT_FAILURE_BYTES = 16 * 1024
29
+ const CLIENT_FAILURE_ROUTE_REGISTRY = Symbol.for('dsh-creator-mode-plus.client-failure-routes.v1')
30
+
31
+ const PLUGIN_ID = /^[a-z][a-z0-9-]*$/
32
+ const KINDS = new Set(['function', 'tool', 'client', 'object', 'class'])
33
+ const CHANGES = new Set(['patch', 'manifest', 'preset', 'client', 'new-client', 'server', 'artifact'])
34
+ const HOT_RELOAD_INFRASTRUCTURE = new Set(['dsh-creator-mode-plus', 'dsh-external-plugin-devkit'])
35
+
36
+ function pluginId(value) {
37
+ if (!PLUGIN_ID.test(value)) throw new Error('plugin name must be lower-case kebab-case')
38
+ return value
39
+ }
40
+
41
+ function choice(value, allowed, label) {
42
+ if (!allowed.has(value)) throw new Error(`${label} is not supported: ${value}`)
43
+ return value
44
+ }
45
+
46
+ const output = {
47
+ schema: { type: 'object', additionalProperties: true },
48
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
49
+ }
50
+
51
+ function isSameOrigin(req) {
52
+ const origin = req.headers.origin
53
+ const authority = req.headers.host
54
+ if (typeof origin !== 'string' || typeof authority !== 'string') return false
55
+ try {
56
+ const parsed = new URL(origin)
57
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === authority
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
63
+ async function readBoundedJson(req) {
64
+ const chunks = []
65
+ let bytes = 0
66
+ for await (const chunk of req) {
67
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
68
+ bytes += buffer.byteLength
69
+ if (bytes > MAX_CLIENT_FAILURE_BYTES) {
70
+ const error = new Error('client failure report is too large')
71
+ error.statusCode = 413
72
+ throw error
73
+ }
74
+ chunks.push(buffer)
75
+ }
76
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
77
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid client failure report')
78
+ return value
79
+ }
80
+
81
+ function clientFailureRouteRegistry() {
82
+ if (globalThis[CLIENT_FAILURE_ROUTE_REGISTRY] === undefined) {
83
+ Object.defineProperty(globalThis, CLIENT_FAILURE_ROUTE_REGISTRY, {
84
+ value: new WeakMap(),
85
+ enumerable: false,
86
+ configurable: false,
87
+ writable: false,
88
+ })
89
+ }
90
+ return globalThis[CLIENT_FAILURE_ROUTE_REGISTRY]
91
+ }
92
+
93
+ function clientFailureHandler(webServer, options) {
94
+ return async (req, res) => {
95
+ if (req.method !== 'POST') {
96
+ res.writeHead(405, { allow: 'POST' })
97
+ res.end()
98
+ return
99
+ }
100
+ if (!isSameOrigin(req)) {
101
+ res.writeHead(403)
102
+ res.end('forbidden')
103
+ return
104
+ }
105
+ try {
106
+ const input = await readBoundedJson(req)
107
+ const result = await (options.runClientFailureDshx ?? runClientFailureDshx)({
108
+ failedIds: input.failedIds,
109
+ message: input.message,
110
+ hostPid: process.pid,
111
+ hostParentPid: process.ppid,
112
+ hostPort: webServer.port,
113
+ }, options)
114
+ if (result.exitCode !== 0) {
115
+ res.writeHead(503, { 'content-type': 'application/json' })
116
+ res.end(JSON.stringify({ reload: false, error: result.stderr || result.stdout || 'recovery failed' }))
117
+ return
118
+ }
119
+ const decoded = JSON.parse(result.stdout || '{}')
120
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
121
+ res.end(JSON.stringify({
122
+ reload: decoded?.data?.reload === true,
123
+ incident: decoded?.data?.incident,
124
+ }))
125
+ } catch (error) {
126
+ const status = Number.isInteger(error?.statusCode) ? error.statusCode : 400
127
+ res.writeHead(status, { 'content-type': 'application/json' })
128
+ res.end(JSON.stringify({ reload: false, error: error instanceof Error ? error.message : String(error) }))
129
+ }
130
+ }
131
+ }
132
+
133
+ function acquireClientFailureRoute(hostKey, webServer, handler) {
134
+ const registry = clientFailureRouteRegistry()
135
+ let broker = registry.get(hostKey)
136
+ const lease = { handler }
137
+ if (broker === undefined) {
138
+ broker = { port: webServer.port, leases: new Set([lease]), current: lease, dispose: undefined }
139
+ broker.dispose = webServer.register({
140
+ kind: 'exact',
141
+ path: CLIENT_FAILURE_PATH,
142
+ handler: (req, res) => broker.current.handler(req, res),
143
+ })
144
+ registry.set(hostKey, broker)
145
+ } else {
146
+ if (broker.port !== webServer.port) throw new Error('Creator+ Web Host changed while route leases remain active')
147
+ broker.leases.add(lease)
148
+ broker.current = lease
149
+ }
150
+
151
+ return () => {
152
+ if (!broker.leases.delete(lease)) return
153
+ if (broker.leases.size === 0) {
154
+ broker.dispose()
155
+ if (registry.get(hostKey) === broker) registry.delete(hostKey)
156
+ return
157
+ }
158
+ if (broker.current !== lease) return
159
+ for (const remaining of broker.leases) broker.current = remaining
160
+ }
161
+ }
162
+
163
+ /** Register one generation-safe same-origin browser sentry route per Web Host. */
164
+ export function installClientFailureRoute(ctx, options = {}) {
165
+ const webServer = ctx.webServer
166
+ // Cordis returns scope-bound service proxies; their object identity is not a
167
+ // Host identity. The public root Context is shared by all preset generations.
168
+ // Raw-service fixtures without a Context retain their original identity.
169
+ const hostKey = ctx.root ?? webServer
170
+ ctx.effect(
171
+ () => acquireClientFailureRoute(hostKey, webServer, clientFailureHandler(webServer, options)),
172
+ 'Creator Mode+ browser failure route',
173
+ )
174
+ }
175
+
176
+ /** Register file-backed Creator Mode+ operations for one preset scope. */
177
+ export function apply(ctx) {
178
+ console.log('[dsh-creator-mode-plus] loaded')
179
+ try {
180
+ const healed = healCreatorPlusPresets()
181
+ if (healed.length > 0) {
182
+ ctx.logger?.info?.(`dsh-creator-mode-plus: migrated 0.1.5 persona in ${healed.join(', ')}`)
183
+ }
184
+ } catch (error) {
185
+ ctx.logger?.warn?.(`dsh-creator-mode-plus: persona heal failed: ${error instanceof Error ? error.message : String(error)}`)
186
+ }
187
+ const authOptions = { getWebStartupUrl: port => typeof ctx.connection.authenticatedUrl === 'function' ? ctx.connection.authenticatedUrl(`http://127.0.0.1:${port}/`) : undefined }
188
+ installCreatorRecovery(ctx, authOptions)
189
+ installClientFailureRoute(ctx, authOptions)
190
+ installCreatorSafetyGuard(ctx)
191
+ installTakeoverFence(ctx)
192
+
193
+ ctx.tools.register({
194
+ name: 'dshx_claim_plugin',
195
+ description: 'Claim one plugin for this Creator+ session and arm the external Guardian before editing. Different sessions may claim different plugins concurrently.',
196
+ parameters: {
197
+ type: 'object',
198
+ properties: { name: { type: 'string', description: 'Plugin id this session will own' } },
199
+ required: ['name'],
200
+ additionalProperties: false,
201
+ },
202
+ timeoutMs: 30_000,
203
+ output,
204
+ execute(args, exec) {
205
+ const id = pluginId(args.name)
206
+ return runDshx(['creator', 'claim', id], exec, { ...authOptions, hostPort: currentWebPort() }).then((result) => {
207
+ if (result.exitCode === 0) rememberCreatorClaim(exec, id)
208
+ return result
209
+ })
210
+ },
211
+ presentCall: args => ({ card: 'generic', title: `dshx claim ${args.name}`, kind: 'edit', rawInput: args.name }),
212
+ })
213
+
214
+ ctx.tools.register({
215
+ name: 'dshx_request_takeover',
216
+ description: 'Ask the user in this conversation to take over a claimed plugin. Shows the actual owner, stops old work after confirmation, and transfers ownership. Never infer approval from chat text or automation.',
217
+ parameters: { type: 'object', properties: { name: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' } }, required: ['name'], additionalProperties: false },
218
+ timeoutMs: 360_000,
219
+ output,
220
+ execute: (args, exec) => requestTakeover(ctx, pluginId(args.name), exec, { ...authOptions, hostPort: currentWebPort() }),
221
+ presentCall: args => ({ card: 'generic', title: `申请接管 ${args.name}`, kind: 'edit', rawInput: args.name }),
222
+ })
223
+
224
+ ctx.tools.register({
225
+ name: 'dshx_scaffold',
226
+ description: 'Create one plugin inside the calling session workspace and mount it into Harness automatically when needed. It never overwrites an existing project.',
227
+ parameters: {
228
+ type: 'object',
229
+ properties: {
230
+ name: { type: 'string', description: 'Lower-case kebab-case plugin id' },
231
+ kind: { type: 'string', description: 'function, tool, client, object, or class' },
232
+ },
233
+ required: ['name', 'kind'],
234
+ additionalProperties: false,
235
+ },
236
+ timeoutMs: 60_000,
237
+ output,
238
+ execute(args, exec) {
239
+ const id = pluginId(args.name)
240
+ return runClaimedDshx(id, [
241
+ 'creator', 'scaffold', id, choice(args.kind, KINDS, 'plugin kind'),
242
+ ], exec, { ...authOptions, hostPort: currentWebPort() })
243
+ },
244
+ presentCall: args => ({ card: 'generic', title: `dshx scaffold ${args.name}`, kind: 'edit', rawInput: args }),
245
+ })
246
+
247
+ ctx.tools.register({
248
+ name: 'dshx_check',
249
+ description: 'Run DSHX v0.7 external-plugin checks, including client Cordis service injection and the built-client handoff. Passing proves SOURCE_BUILT only, not live activation.',
250
+ parameters: {
251
+ type: 'object',
252
+ properties: { name: { type: 'string', description: 'Plugin id under my-plugins' } },
253
+ required: ['name'],
254
+ additionalProperties: false,
255
+ },
256
+ timeoutMs: 60_000,
257
+ output,
258
+ execute(args, exec) {
259
+ const id = pluginId(args.name)
260
+ return runClaimedDshx(id, ['check', id], exec, { ...authOptions, hostPort: currentWebPort() })
261
+ },
262
+ presentCall: args => ({ card: 'generic', title: `dshx check ${args.name}`, kind: 'read', rawInput: args.name }),
263
+ })
264
+
265
+ ctx.tools.register({
266
+ name: 'dshx_activation_plan',
267
+ description: 'Select the activation method for one changed component: patch, manifest, preset, client, new-client, server, or artifact.',
268
+ parameters: {
269
+ type: 'object',
270
+ properties: {
271
+ name: { type: 'string', description: 'Plugin id under my-plugins' },
272
+ change: { type: 'string', description: 'patch, manifest, preset, client, new-client, server, or artifact' },
273
+ },
274
+ required: ['name', 'change'],
275
+ additionalProperties: false,
276
+ },
277
+ timeoutMs: 60_000,
278
+ output,
279
+ execute(args, exec) {
280
+ const id = pluginId(args.name)
281
+ return runClaimedDshx(id, [
282
+ 'activation-plan', id, '--change', choice(args.change, CHANGES, 'change surface'),
283
+ ], exec, { ...authOptions, hostPort: currentWebPort() })
284
+ },
285
+ presentCall: args => ({ card: 'generic', title: `dshx plan ${args.change}`, kind: 'read', rawInput: args }),
286
+ })
287
+
288
+ ctx.tools.register({
289
+ name: 'dshx_activate_new_client',
290
+ description: 'Activate one checked my-plugins Web client in the DSHX v0.7 safe order: profile link, resolution proof, watched-patch transaction, then current-Host manifest proof.',
291
+ parameters: {
292
+ type: 'object',
293
+ properties: { name: { type: 'string', description: 'Plugin id under my-plugins' } },
294
+ required: ['name'],
295
+ additionalProperties: false,
296
+ },
297
+ timeoutMs: 90_000,
298
+ output,
299
+ execute(args, exec) {
300
+ const id = pluginId(args.name)
301
+ const port = currentWebPort()
302
+ return runClaimedDshx(id, [
303
+ 'activate-new-client', id, '--profile', 'web', '--port', String(port),
304
+ ], exec, { ...authOptions, hostPort: port })
305
+ },
306
+ presentCall: args => ({ card: 'generic', title: `dshx activate ${args.name}`, kind: 'edit', rawInput: args.name }),
307
+ })
308
+
309
+ ctx.tools.register({
310
+ name: 'dshx_remove_plugin',
311
+ description: 'Safely deactivate and unregister one claimed plugin. It proves the current Host no longer contains the plugin before removing the Web-profile dependency, detaches only symlinks, preserves source, and never restarts DSH.',
312
+ parameters: {
313
+ type: 'object',
314
+ properties: { name: { type: 'string', description: 'Claimed plugin id to deactivate and unregister' } },
315
+ required: ['name'],
316
+ additionalProperties: false,
317
+ },
318
+ timeoutMs: 90_000,
319
+ output,
320
+ async execute(args, exec) {
321
+ const id = pluginId(args.name)
322
+ const port = currentWebPort()
323
+ const result = await runClaimedDshx(id, ['creator', 'remove', id], exec, { ...authOptions, hostPort: port })
324
+ if (result.exitCode === 0) forgetCreatorClaim(exec)
325
+ return result
326
+ },
327
+ presentCall: args => ({ card: 'generic', title: `dshx remove ${args.name}`, kind: 'edit', rawInput: args.name }),
328
+ })
329
+
330
+ ctx.tools.register({
331
+ name: 'dshx_hot_reload',
332
+ description: 'Activate the code changes of one already-loaded, checked server plugin. The requested behavior remains RUNTIME_VERIFICATION_REQUIRED until exercised. Use the fixed plugin ID; runtime provenance and cleanup are checked internally.',
333
+ parameters: {
334
+ type: 'object',
335
+ properties: { name: { type: 'string', description: 'Existing checked plugin id under my-plugins' } },
336
+ required: ['name'],
337
+ additionalProperties: false,
338
+ },
339
+ timeoutMs: 90_000,
340
+ output,
341
+ execute(args, exec) {
342
+ const id = pluginId(args.name)
343
+ if (HOT_RELOAD_INFRASTRUCTURE.has(id)) {
344
+ throw new Error(`dshx_hot_reload cannot replace its executing infrastructure plugin: ${id}`)
345
+ }
346
+ const port = currentWebPort()
347
+ return runClaimedDshx(id, [
348
+ 'hot-reload', id, '--profile', 'web', '--port', String(port), '--json',
349
+ ], exec, { ...authOptions, hostPort: port })
350
+ },
351
+ presentCall: args => ({ card: 'generic', title: `dshx hot reload ${args.name}`, kind: 'edit', rawInput: args.name }),
352
+ })
353
+
354
+ ctx.tools.register({
355
+ name: 'dshx_browser_open',
356
+ description: "Open the current authenticated DSH WebUI through this session's externally approved browser adapter. No URL, path, credential or command input. BROWSER_AUTHENTICATED proves browser access only; continue feature verification without a Host restart.",
357
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
358
+ timeoutMs: 60_000,
359
+ output,
360
+ execute(_args, exec) {
361
+ return runDshx(['browser', 'open', '--json'], exec, { ...authOptions, hostPort: currentWebPort() })
362
+ },
363
+ presentCall: () => ({ card: 'generic', title: 'Open authenticated DSH browser', kind: 'edit', rawInput: {} }),
364
+ })
365
+
366
+ ctx.tools.register({
367
+ name: 'dshx_status',
368
+ description: 'Read the external DSHX v0.7 supervisor and Web Host status. The bridge also reports its pinned contract and capabilities; it never starts, stops, restarts, or updates DSH.',
369
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
370
+ timeoutMs: 30_000,
371
+ output,
372
+ execute(_args, exec) {
373
+ return runDshx(['status'], exec, { ...authOptions, hostPort: currentWebPort() })
374
+ },
375
+ presentCall: () => ({ card: 'generic', title: 'dshx status', kind: 'read' }),
376
+ })
377
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ const PRESET_IDS = ['creator-plus', 'creator-mode-plus']
6
+
7
+ /** Rewrite a leftover 0.1.2 `text` persona field to the 0.1.5 `prefix` field. */
8
+ export function migratePersona015(text) {
9
+ return text.replace(/(^|\n)( )text: ([|>]-?)/g, '$1$2prefix: $3')
10
+ }
11
+
12
+ /**
13
+ * Heal already-installed Creator+ compositions in DSH_HOME.
14
+ * The Host plugin can run this even when a stale preset fails to mount.
15
+ */
16
+ export function healCreatorPlusPresets(home = process.env.DSH_HOME || join(homedir(), '.dsh')) {
17
+ const healed = []
18
+ for (const id of PRESET_IDS) {
19
+ const path = join(home, '.agent-presets', id, 'agent.cordis.yml')
20
+ if (!existsSync(path)) continue
21
+ const before = readFileSync(path, 'utf8')
22
+ const after = migratePersona015(before)
23
+ if (after === before) continue
24
+ writeFileSync(path, after)
25
+ healed.push(path)
26
+ }
27
+ return healed
28
+ }