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/runner.js ADDED
@@ -0,0 +1,464 @@
1
+ import { privateStartupUrl, redactStartupOutput } from './auth.js'
2
+ /** Fixed-argument bridge from DSH tools to the external dshx CLI. */
3
+
4
+ import { spawn } from 'node:child_process'
5
+ import { randomUUID } from 'node:crypto'
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { createRequire } from 'node:module'
8
+ import { homedir } from 'node:os'
9
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import {
12
+ CREATOR_BRIDGE_VERSION,
13
+ DSHX_CONTRACT,
14
+ inspectDshxCompatibility,
15
+ } from './compatibility.js'
16
+ import { forgetCreatorClaim, rememberCreatorClaim } from './safety.js'
17
+ import { deliveryStatus, readDelivery, recordDelivery, verifyDeliveryClient, withOperationOutcome } from './delivery.js'
18
+
19
+ export {
20
+ CREATOR_BRIDGE_VERSION,
21
+ DSHX_CONTRACT,
22
+ inspectDshxCompatibility,
23
+ SUPPORTED_DSHX,
24
+ supportsDshxVersion,
25
+ } from './compatibility.js'
26
+
27
+ const MAX_CAPTURE_BYTES = 64 * 1024
28
+ const CLIENT_FAILURE_TIMEOUT_MS = 15_000
29
+ const PLUGIN_ID = /^[a-z][a-z0-9-]*$/
30
+ const KINDS = new Set(['function', 'tool', 'client', 'object', 'class'])
31
+ const CHANGES = new Set(['patch', 'manifest', 'preset', 'client', 'new-client', 'server', 'artifact'])
32
+
33
+ function isAllowedArgs(args) {
34
+ if (args.length === 1) return args[0] === 'status'
35
+ if (args.length === 2) return args[0] === 'check' && PLUGIN_ID.test(args[1])
36
+ if (args.length === 3 && args[0] === 'browser' && args[1] === 'open' && args[2] === '--json') return true
37
+ if (args.length === 3) {
38
+ return args[0] === 'creator'
39
+ && (((args[1] === 'claim' || args[1] === 'remove') && PLUGIN_ID.test(args[2]))
40
+ || ((args[1] === 'watch' || args[1] === 'release') && args[2] === '--json'))
41
+ }
42
+ if (args.length === 4) {
43
+ if (args[0] === 'creator') {
44
+ if (args[1] === 'takeover' && PLUGIN_ID.test(args[2]) && args[3] === '--json') return true
45
+ return (args[1] === 'recovery'
46
+ && args[2] === 'pull'
47
+ && args[3] === '--json')
48
+ || (args[1] === 'scaffold'
49
+ && PLUGIN_ID.test(args[2])
50
+ && KINDS.has(args[3]))
51
+ }
52
+ if (!PLUGIN_ID.test(args[1])) return false
53
+ return args[0] === 'activation-plan'
54
+ && args[2] === '--change'
55
+ && CHANGES.has(args[3])
56
+ }
57
+ if (args.length === 5) {
58
+ return args[0] === 'creator'
59
+ && args[1] === 'recovery'
60
+ && args[2] === 'ack'
61
+ && /^[0-9a-f-]{36}$/.test(args[3])
62
+ && args[4] === '--json'
63
+ }
64
+ if (args.length === 6 && args[0] === 'activate-new-client' && PLUGIN_ID.test(args[1])) {
65
+ return args[2] === '--profile'
66
+ && args[3] === 'web'
67
+ && args[4] === '--port'
68
+ && /^\d{1,5}$/.test(args[5])
69
+ && Number(args[5]) >= 1
70
+ && Number(args[5]) <= 65_535
71
+ }
72
+ if (args.length === 7 && args[0] === 'hot-reload' && PLUGIN_ID.test(args[1])) {
73
+ return args[2] === '--profile'
74
+ && args[3] === 'web'
75
+ && args[4] === '--port'
76
+ && /^\d{1,5}$/.test(args[5])
77
+ && Number(args[5]) >= 1
78
+ && Number(args[5]) <= 65_535
79
+ && args[6] === '--json'
80
+ }
81
+ return false
82
+ }
83
+
84
+ /** Resolve the current official Web profile's loopback port without model input. */
85
+ export function currentWebPort(argv = process.argv) {
86
+ const webAlias = argv.includes('web')
87
+ const profileIndex = argv.lastIndexOf('--profile')
88
+ const webProfile = profileIndex >= 0 && argv[profileIndex + 1] === 'web'
89
+ if (!webAlias && !webProfile) {
90
+ throw new Error('dsh-creator-mode-plus: activation requires the current Web profile')
91
+ }
92
+ const portIndex = argv.lastIndexOf('--port')
93
+ const value = portIndex >= 0 ? Number(argv[portIndex + 1]) : 3080
94
+ if (!Number.isInteger(value) || value < 1 || value > 65_535) {
95
+ throw new Error('dsh-creator-mode-plus: current Web profile does not expose a valid TCP port')
96
+ }
97
+ return value
98
+ }
99
+
100
+ function contextFromExecution(exec, hostPort) {
101
+ const sessionId = exec?.agent?.id
102
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
103
+ throw new Error('dsh-creator-mode-plus: bridge v2 requires the calling DSH session identity')
104
+ }
105
+ const callId = typeof exec.callId === 'string' ? exec.callId : undefined
106
+ const rootCallId = typeof exec.rootCallId === 'string' ? exec.rootCallId : callId
107
+ const workspaceRoot = exec?.agent?.session?.header?.cwd
108
+ if (workspaceRoot !== undefined && (
109
+ typeof workspaceRoot !== 'string'
110
+ || workspaceRoot.length === 0
111
+ || workspaceRoot.length > 4_096
112
+ || !isAbsolute(workspaceRoot)
113
+ )) {
114
+ throw new Error('dsh-creator-mode-plus: bridge v2 requires an absolute bounded session workspace')
115
+ }
116
+ return {
117
+ sessionId,
118
+ ...callId ? { callId } : {},
119
+ ...rootCallId ? { rootCallId } : {},
120
+ hostPid: process.pid,
121
+ hostParentPid: process.ppid,
122
+ hostPort,
123
+ bridgeVersion: CREATOR_BRIDGE_VERSION,
124
+ ...workspaceRoot === undefined ? {} : { workspaceRoot: resolve(workspaceRoot) },
125
+ }
126
+ }
127
+
128
+ function isHarnessRoot(path) {
129
+ return existsSync(join(path, 'apps/cli/src/bin.ts'))
130
+ && existsSync(join(path, 'tools/dshx/src/cli.ts'))
131
+ && existsSync(join(path, 'tools/dshx/package.json'))
132
+ }
133
+
134
+ function walkForHarness(start) {
135
+ let cursor = resolve(start)
136
+ while (true) {
137
+ if (isHarnessRoot(cursor)) return cursor
138
+ const parent = dirname(cursor)
139
+ if (parent === cursor) return undefined
140
+ cursor = parent
141
+ }
142
+ }
143
+
144
+ /** Resolve exactly one Harness checkout using the same authority rule as dshx. */
145
+ export function resolveHarnessRoot(options = {}) {
146
+ if (options.harnessRoot) {
147
+ const explicit = resolve(options.harnessRoot)
148
+ if (!isHarnessRoot(explicit)) {
149
+ throw new Error(`dsh-creator-mode-plus: --harness is not a checkout with tools/dshx: ${explicit}`)
150
+ }
151
+ return explicit
152
+ }
153
+
154
+ const candidates = []
155
+ const addCandidate = (path, source) => {
156
+ const root = resolve(path)
157
+ const existing = candidates.find(candidate => candidate.root === root)
158
+ if (existing) existing.sources.push(source)
159
+ else candidates.push({ root, sources: [source] })
160
+ }
161
+
162
+ const configured = options.envRoot === undefined
163
+ ? process.env.DSHX_HARNESS?.trim()
164
+ : options.envRoot?.trim()
165
+ if (configured) {
166
+ if (!isHarnessRoot(configured)) {
167
+ throw new Error(`dsh-creator-mode-plus: DSHX_HARNESS is not a Harness checkout with tools/dshx: ${resolve(configured)}`)
168
+ }
169
+ addCandidate(configured, 'env')
170
+ }
171
+
172
+ const configFile = options.configFile === undefined
173
+ ? join(homedir(), '.config/dshx/harness')
174
+ : options.configFile
175
+ if (configFile && existsSync(configFile)) {
176
+ const value = readFileSync(configFile, 'utf8').trim()
177
+ if (value) {
178
+ if (!isHarnessRoot(value)) {
179
+ throw new Error(`dsh-creator-mode-plus: ${configFile} is not a Harness checkout with tools/dshx: ${resolve(value)}`)
180
+ }
181
+ addCandidate(value, 'config')
182
+ }
183
+ }
184
+
185
+ const fromCwd = walkForHarness(options.cwd ?? process.cwd())
186
+ if (fromCwd) addCandidate(fromCwd, 'cwd')
187
+
188
+ const moduleDir = options.moduleDir ?? dirname(fileURLToPath(import.meta.url))
189
+ const fromModule = walkForHarness(moduleDir)
190
+ if (fromModule) addCandidate(fromModule, 'module')
191
+
192
+ if (candidates.length === 0) {
193
+ throw new Error('dsh-creator-mode-plus: no Harness checkout found; run dshx setup, pass --harness, or set DSHX_HARNESS')
194
+ }
195
+ if (candidates.length > 1) {
196
+ const listed = candidates
197
+ .map(candidate => `${candidate.sources.join('+')}: ${candidate.root}`)
198
+ .join('; ')
199
+ throw new Error(`dsh-creator-mode-plus: multiple Harness checkouts found (${listed}); pass an explicit --harness`)
200
+ }
201
+ return candidates[0].root
202
+ }
203
+
204
+ /** Resolve and version-gate the external dshx bridge implementation. */
205
+ export function resolveDshxRuntime(options = {}) {
206
+ const root = resolveHarnessRoot(options)
207
+ const compatibility = inspectDshxCompatibility(root)
208
+
209
+ const cli = join(root, 'tools/dshx/src/cli.ts')
210
+ const loader = options.loaderPath
211
+ || createRequire(compatibility.packagePath).resolve('tsx/esm')
212
+ return {
213
+ root,
214
+ cli,
215
+ loader,
216
+ dshxVersion: compatibility.dshxVersion,
217
+ bridgeVersion: CREATOR_BRIDGE_VERSION,
218
+ contractId: compatibility.contractId,
219
+ capabilities: compatibility.capabilities,
220
+ }
221
+ }
222
+
223
+ function appendBounded(current, chunk) {
224
+ const combined = Buffer.concat([Buffer.from(current, 'utf8'), chunk])
225
+ if (combined.byteLength <= MAX_CAPTURE_BYTES) return combined.toString('utf8')
226
+ return `[earlier output truncated]\n${combined.subarray(-MAX_CAPTURE_BYTES).toString('utf8')}`
227
+ }
228
+
229
+ /** Execute one allowlisted dshx operation with structured session provenance. */
230
+ function executeDshx(args, exec, options = {}) {
231
+ if (!Array.isArray(args) || !args.every(value => typeof value === 'string') || !isAllowedArgs(args)) {
232
+ throw new Error('dsh-creator-mode-plus: refusing an operation outside bridge v2')
233
+ }
234
+ const runtime = resolveDshxRuntime(options)
235
+ const argv = ['--import', runtime.loader, runtime.cli, ...args, ...args[0] === 'activation-plan' ? ['--json'] : [], '--harness', runtime.root]
236
+ const spawnProcess = options.spawnProcess ?? spawn
237
+ const hostPort = options.hostPort ?? currentWebPort()
238
+ const creatorContext = contextFromExecution(exec, hostPort)
239
+ const startup = privateStartupUrl(options.getWebStartupUrl, hostPort)
240
+ const signal = exec?.signal
241
+ return new Promise((resolveResult, reject) => {
242
+ const child = spawnProcess(process.execPath, argv, {
243
+ cwd: runtime.root,
244
+ env: {
245
+ ...process.env,
246
+ DSHX_HARNESS: runtime.root,
247
+ DSHX_CREATOR_CONTEXT: JSON.stringify(creatorContext),
248
+ DSHX_CREATOR_TAKEOVER_GRANT: args[0] === 'creator' && args[1] === 'takeover' && options.takeoverGrant ? JSON.stringify(options.takeoverGrant) : '',
249
+ DSHX_WEB_STARTUP_URL: startup,
250
+ },
251
+ stdio: ['ignore', 'pipe', 'pipe'],
252
+ })
253
+ let stdout = ''
254
+ let stderr = ''
255
+ child.stdout.on('data', chunk => { stdout = appendBounded(stdout, chunk) })
256
+ child.stderr.on('data', chunk => { stderr = appendBounded(stderr, chunk) })
257
+
258
+ const abort = () => { child.kill('SIGTERM') }
259
+ if (signal?.aborted) abort()
260
+ else signal?.addEventListener('abort', abort, { once: true })
261
+ child.once('error', reject)
262
+ child.once('close', (code) => {
263
+ signal?.removeEventListener('abort', abort)
264
+ resolveResult({
265
+ command: `dshx ${args.join(' ')}`,
266
+ exitCode: code ?? 1,
267
+ stdout: redactStartupOutput(stdout.trim(), startup),
268
+ stderr: redactStartupOutput(stderr.trim(), startup),
269
+ dshxVersion: runtime.dshxVersion,
270
+ creatorBridgeVersion: runtime.bridgeVersion,
271
+ dshxContract: runtime.contractId,
272
+ dshxCapabilities: runtime.capabilities,
273
+ ...((args[0] === 'activate-new-client' || args[0] === 'hot-reload')
274
+ ? { hostPid: process.pid, hostPort: Number(args[5]) }
275
+ : {}),
276
+ })
277
+ })
278
+ })
279
+ }
280
+
281
+ export function runDshx(args, exec, options = {}) {
282
+ if (!Array.isArray(args) || !args.every(value => typeof value === 'string') || !isAllowedArgs(args)) {
283
+ throw new Error('dsh-creator-mode-plus: refusing an operation outside bridge v2')
284
+ }
285
+ let root
286
+ return Promise.resolve().then(() => executeDshx(args, exec, options)).then(async result => {
287
+ root = resolveHarnessRoot(options)
288
+ // No receipt is inferred from an empty/malformed subprocess response.
289
+ if ((result.stdout || args[0] === 'hot-reload') && ['activation-plan', 'check', 'hot-reload'].includes(args[0])) {
290
+ recordDelivery(root, args, result, exec.agent.id)
291
+ }
292
+ const port = options.hostPort ?? currentWebPort()
293
+ const receipt = readDelivery(root, exec.agent.id)
294
+ const delivery = deliveryStatus(receipt, { port })
295
+ if (args[0] === 'status' && delivery?.state === 'RUNTIME_VERIFICATION_REQUIRED') {
296
+ try { delivery.runtimeProof = await verifyDeliveryClient(receipt, port, options.getWebStartupUrl?.(port)) }
297
+ catch { delivery.runtimeProof = { state: 'RUNTIME_PROOF_FAILED', behavior: 'UNVERIFIED' } }
298
+ }
299
+ return withOperationOutcome(args, result, delivery)
300
+ }).catch((error) => {
301
+ if (args[0] === 'hot-reload') {
302
+ root ??= resolveHarnessRoot(options)
303
+ recordDelivery(root, args, { exitCode: 1, stdout: '', stderr: '' }, exec.agent.id)
304
+ }
305
+ throw error
306
+ })
307
+ }
308
+
309
+ /** Forward one browser-loader failure through fixed argv and Host-owned identity. */
310
+ export function runClientFailureDshx(report, options = {}) {
311
+ const runtime = resolveDshxRuntime(options)
312
+ const argv = ['--import', runtime.loader, runtime.cli, 'creator', 'client-failure', '--json']
313
+ const spawnProcess = options.spawnProcess ?? spawn
314
+ const startup = privateStartupUrl(options.getWebStartupUrl, report.hostPort)
315
+ const { DSHX_CREATOR_CONTEXT: _discardContext, DSHX_CREATOR_CLIENT_FAILURE: _discardFailure, ...baseEnv } = process.env
316
+ return new Promise((resolveResult, reject) => {
317
+ const child = spawnProcess(process.execPath, argv, {
318
+ cwd: runtime.root,
319
+ env: {
320
+ ...baseEnv,
321
+ DSHX_HARNESS: runtime.root,
322
+ DSHX_CREATOR_CLIENT_FAILURE: JSON.stringify(report),
323
+ DSHX_WEB_STARTUP_URL: startup,
324
+ },
325
+ stdio: ['ignore', 'pipe', 'pipe'],
326
+ })
327
+ let stdout = ''
328
+ let stderr = ''
329
+ let timedOut = false
330
+ const timer = setTimeout(() => {
331
+ timedOut = true
332
+ child.kill('SIGTERM')
333
+ }, options.timeoutMs ?? CLIENT_FAILURE_TIMEOUT_MS)
334
+ child.stdout.on('data', chunk => { stdout = appendBounded(stdout, chunk) })
335
+ child.stderr.on('data', chunk => { stderr = appendBounded(stderr, chunk) })
336
+ child.once('error', (error) => {
337
+ clearTimeout(timer)
338
+ reject(error)
339
+ })
340
+ child.once('close', (code) => {
341
+ clearTimeout(timer)
342
+ resolveResult({
343
+ command: 'dshx creator client-failure',
344
+ exitCode: timedOut ? 124 : (code ?? 1),
345
+ stdout: redactStartupOutput(stdout.trim(), startup),
346
+ stderr: redactStartupOutput(timedOut ? `${stderr.trim()}\nclient-failure recovery timed out`.trim() : stderr.trim(), startup),
347
+ dshxVersion: runtime.dshxVersion,
348
+ creatorBridgeVersion: runtime.bridgeVersion,
349
+ dshxContract: runtime.contractId,
350
+ dshxCapabilities: runtime.capabilities,
351
+ })
352
+ })
353
+ })
354
+ }
355
+
356
+ /** Refresh the session/plugin lease before every named plugin operation. */
357
+ export async function runClaimedDshx(pluginId, args, exec, options = {}) {
358
+ let claim
359
+ try {
360
+ claim = await runDshx(['creator', 'claim', pluginId], exec, options)
361
+ } catch (error) {
362
+ if (args[0] === 'hot-reload') {
363
+ recordDelivery(resolveHarnessRoot(options), args, { exitCode: 1, stdout: '', stderr: '' }, exec.agent.id)
364
+ }
365
+ throw error
366
+ }
367
+ if (claim.exitCode !== 0) {
368
+ if (args[0] === 'hot-reload') {
369
+ const root = resolveHarnessRoot(options)
370
+ recordDelivery(root, args, claim, exec.agent.id)
371
+ const { delivery: _staleDelivery, ...withoutStaleDelivery } = claim
372
+ const delivery = deliveryStatus(readDelivery(root, exec.agent.id), {
373
+ port: options.hostPort ?? currentWebPort(),
374
+ })
375
+ return delivery ? { ...withoutStaleDelivery, delivery } : withoutStaleDelivery
376
+ }
377
+ return claim
378
+ }
379
+ rememberCreatorClaim(exec, pluginId)
380
+ const result = await runDshx(args, exec, options)
381
+ return { ...result, claim: { sessionId: exec.agent.id, pluginId } }
382
+ }
383
+
384
+ function recoveryText(incident) {
385
+ const lines = [
386
+ `[Creator+ Guardian incident ${incident.id}]`,
387
+ incident.summary,
388
+ incident.pluginId ? `Attributed plugin: ${incident.pluginId}` : 'Attributed plugin: ambiguous',
389
+ `Rollback: ${incident.rollback}`,
390
+ incident.previousPid ? `Previous Host pid: ${incident.previousPid}` : undefined,
391
+ incident.recoveredPid ? `Recovered Host pid: ${incident.recoveredPid}` : undefined,
392
+ incident.port ? `Recovered Web port: ${incident.port}` : undefined,
393
+ incident.logExcerpt ? `Diagnostic excerpt:\n${incident.logExcerpt}` : undefined,
394
+ 'Inspect the preserved source and incident evidence, fix the plugin, run dshx_check, and only then retry the classified activation branch.',
395
+ ]
396
+ return lines.filter(Boolean).join('\n')
397
+ }
398
+
399
+ function pluginMessage(text) {
400
+ return Object.freeze({
401
+ id: randomUUID(),
402
+ role: 'user',
403
+ content: Object.freeze([{ type: 'text', text }]),
404
+ source: Object.freeze({ kind: 'plugin', plugin: 'dsh-creator-mode-plus' }),
405
+ })
406
+ }
407
+
408
+ /** Deliver pending external incidents when their exact Creator+ session starts or resumes. */
409
+ export async function deliverCreatorRecovery(agent, options = {}) {
410
+ const callId = `creator-recovery-${randomUUID()}`
411
+ const exec = {
412
+ agent,
413
+ callId,
414
+ rootCallId: callId,
415
+ signal: options.signal ?? AbortSignal.timeout(15_000),
416
+ }
417
+ const run = options.runDshx ?? runDshx
418
+ const watch = await run(['creator', 'watch', '--json'], exec, options)
419
+ if (watch.exitCode !== 0) throw new Error(watch.stderr || watch.stdout || 'Creator+ Guardian arm failed')
420
+ const pull = await run(['creator', 'recovery', 'pull', '--json'], exec, options)
421
+ if (pull.exitCode !== 0) throw new Error(pull.stderr || pull.stdout || 'Creator+ recovery pull failed')
422
+ const decoded = JSON.parse(pull.stdout || '{}')
423
+ const incidents = Array.isArray(decoded?.data?.incidents) ? decoded.data.incidents : []
424
+ for (const incident of incidents) {
425
+ agent.steer(pluginMessage(recoveryText(incident)))
426
+ const ack = await run(['creator', 'recovery', 'ack', incident.id, '--json'], exec, options)
427
+ if (ack.exitCode !== 0) throw new Error(ack.stderr || ack.stdout || `Creator+ recovery ack failed for ${incident.id}`)
428
+ }
429
+ const root = options.harnessRoot ?? (options.runDshx ? undefined : resolveHarnessRoot(options))
430
+ const delivery = root ? deliveryStatus(readDelivery(root, agent.id), { port: options.hostPort ?? currentWebPort() }) : undefined
431
+ if (delivery) agent.steer(pluginMessage(`[Creator+ pending delivery]\n${JSON.stringify(delivery)}\nContinue verification of this existing plugin; do not report delivery complete before its requested behavior works.`))
432
+ return incidents
433
+ }
434
+
435
+ export async function releaseCreatorClaim(agent, options = {}) {
436
+ const callId = `creator-release-${randomUUID()}`
437
+ const result = await (options.runDshx ?? runDshx)(['creator', 'release', '--json'], {
438
+ agent,
439
+ callId,
440
+ rootCallId: callId,
441
+ signal: AbortSignal.timeout(5_000),
442
+ }, options)
443
+ if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || 'Creator+ claim release failed')
444
+ forgetCreatorClaim({ agent })
445
+ }
446
+
447
+ /** Register fail-contained recovery delivery on the preset-scoped Agent lifecycle. */
448
+ export function installCreatorRecovery(ctx, options = {}) {
449
+ if (typeof ctx.on !== 'function') return
450
+ ctx.on('agent/session-start', ({ agent }) => {
451
+ void deliverCreatorRecovery(agent, options).catch((error) => {
452
+ const message = `dsh-creator-mode-plus: recovery delivery failed: ${error instanceof Error ? error.message : String(error)}`
453
+ if (ctx.logger?.warn) ctx.logger.warn(message)
454
+ else console.warn(message)
455
+ })
456
+ })
457
+ ctx.on('agent/disposed', ({ agent }) => {
458
+ void releaseCreatorClaim(agent, options).catch((error) => {
459
+ const message = `dsh-creator-mode-plus: claim release failed: ${error instanceof Error ? error.message : String(error)}`
460
+ if (ctx.logger?.warn) ctx.logger.warn(message)
461
+ else console.warn(message)
462
+ })
463
+ })
464
+ }
package/src/safety.js ADDED
@@ -0,0 +1,79 @@
1
+ /** Creator Mode+ claim tracking and last-mile destructive-shell guard. */
2
+
3
+ const claimedPlugins = new WeakMap()
4
+ const PLUGIN_ID = /^[a-z][a-z0-9-]*$/
5
+
6
+ function record(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
8
+ }
9
+
10
+ function sessionWorkspace(exec) {
11
+ const cwd = exec?.agent?.session?.header?.cwd
12
+ return typeof cwd === 'string' && cwd.startsWith('/') ? cwd.replace(/\/$/, '') : undefined
13
+ }
14
+
15
+ function commandFrom(exec) {
16
+ const args = record(exec?.arguments)
17
+ return typeof args?.command === 'string' ? args.command : undefined
18
+ }
19
+
20
+ function destructiveShell(command) {
21
+ return /(?:^|[;&|()\n]\s*)(?:(?:command|env|sudo)\s+)*(?:(?:\/[\w@.+-]+)+\/)?(?:rm|rmdir|unlink|mv)\b/.test(command)
22
+ }
23
+
24
+ function containsPath(command, path) {
25
+ return command.includes(path)
26
+ || command.includes(JSON.stringify(path))
27
+ || command.includes(`'${path.replaceAll("'", "'\\''")}'`)
28
+ }
29
+
30
+ /** Remember the exact plugin claimed by the current agent object. */
31
+ export function rememberCreatorClaim(exec, pluginId) {
32
+ if (exec?.agent && PLUGIN_ID.test(pluginId)) claimedPlugins.set(exec.agent, pluginId)
33
+ }
34
+
35
+ /** Forget a claim after safe removal or preset disposal. */
36
+ export function forgetCreatorClaim(exec) {
37
+ if (exec?.agent) claimedPlugins.delete(exec.agent)
38
+ }
39
+
40
+ export function claimedCreatorPlugin(exec) {
41
+ return exec?.agent ? claimedPlugins.get(exec.agent) : undefined
42
+ }
43
+
44
+ /**
45
+ * Deny only plugin-root and active-profile teardown. Normal edits and removal
46
+ * of files inside a plugin remain available through bash.
47
+ */
48
+ export function creatorDestructiveCommandReason(exec, explicitPluginId) {
49
+ if (exec?.name !== 'bash') return undefined
50
+ const command = commandFrom(exec)
51
+ if (!command || !destructiveShell(command)) return undefined
52
+
53
+ if (/(?:^|[\s'"=])(?:\/Users\/[^/]+\/)?\.dsh\/profiles(?:\/|[\s'";]|$)/.test(command)
54
+ || /\/\.dsh\/profiles(?:\/|[\s'";]|$)/.test(command)) {
55
+ return 'Creator Mode+ blocks direct teardown of the active DSH profile. Use dshx_remove_plugin so the live Host row is removed before its profile dependency.'
56
+ }
57
+
58
+ const pluginId = explicitPluginId ?? claimedCreatorPlugin(exec)
59
+ if (!pluginId) return undefined
60
+ const workspace = sessionWorkspace(exec)
61
+ const pluginRoot = workspace ? `${workspace}/${pluginId}` : undefined
62
+ const protectsHarnessLink = command.includes(`/my-plugins/${pluginId}`)
63
+ const protectsWorkspaceRoot = pluginRoot ? containsPath(command, pluginRoot) : false
64
+ const escapedId = pluginId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
65
+ const removesClaimedRelativeRoot = new RegExp(`(?:^|[\\s'"=;])(?:\\.\\/)?${escapedId}(?:[\\s'";]|$)`).test(command)
66
+ const removesCurrentRoot = workspace?.endsWith(`/${pluginId}`) === true
67
+ && /(?:^|[;&|()\n]\s*)(?:(?:command|env|sudo)\s+)*(?:(?:\/[\w@.+-]+)+\/)?(?:rm|rmdir|unlink|mv)\s+(?:-[^\s]+\s+)*(?:\.|\.\/)(?:[\s;]|$)/.test(command)
68
+
69
+ if (protectsHarnessLink || protectsWorkspaceRoot || removesClaimedRelativeRoot || removesCurrentRoot) {
70
+ return `Creator Mode+ blocks direct teardown of claimed plugin ${pluginId}. Use dshx_remove_plugin; it deactivates the live Host first and preserves source.`
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ /** Install the preset-scoped monotonic guard when the Host exposes it. */
76
+ export function installCreatorSafetyGuard(ctx) {
77
+ if (typeof ctx?.tools?.guard !== 'function') return
78
+ ctx.tools.guard(exec => creatorDestructiveCommandReason(exec))
79
+ }