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/AGENTS.md +40 -0
- package/CHANGELOG.md +104 -0
- package/LICENSE +21 -0
- package/README.en.md +83 -0
- package/README.md +82 -0
- package/docs/bridge-contract.md +431 -0
- package/docs/dshx-v0.7-alignment.md +108 -0
- package/docs/screenshots/already-installed.png +0 -0
- package/docs/screenshots/install.png +0 -0
- package/docs/screenshots/mode-picker.gif +0 -0
- package/docs/screenshots/mode-picker.png +0 -0
- package/docs/screenshots/mode-selected.png +0 -0
- package/dshx.yml +16 -0
- package/package.json +67 -0
- package/preset/preset.yml +2 -0
- package/preset/skills/creator-mode-plus/SKILL.md +101 -0
- package/preset/skills/creator-mode-plus/existing-plugin-trials.md +60 -0
- package/preset/skills/creator-mode-plus/maintenance.md +48 -0
- package/preset/skills/creator-mode-plus/verification-recovery.md +54 -0
- package/scripts/install.mjs +292 -0
- package/scripts/verify-dshx.mjs +77 -0
- package/scripts/verify-harness-install.mjs +101 -0
- package/src/auth.js +26 -0
- package/src/client.js +112 -0
- package/src/compatibility.js +261 -0
- package/src/delivery.js +331 -0
- package/src/index.js +377 -0
- package/src/preset-015.js +28 -0
- package/src/runner.js +464 -0
- package/src/safety.js +79 -0
- package/src/takeover.js +157 -0
package/src/takeover.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Human-confirmed ownership handoff, using public DSH services only. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { currentWebPort, resolveHarnessRoot, runDshx } from './runner.js'
|
|
7
|
+
import { rememberCreatorClaim } from './safety.js'
|
|
8
|
+
const BROKERS = Symbol.for('dshx.creator-ownership-fence.v1')
|
|
9
|
+
const passive = new Set(['dshx_status', 'dshx_request_takeover'])
|
|
10
|
+
const liveJob = job => job.status === 'running' || job.status === 'stopping'
|
|
11
|
+
const get = (ctx, name) => typeof ctx.get === 'function' ? ctx.get(name) : ctx[name]
|
|
12
|
+
function registry() {
|
|
13
|
+
if (!globalThis[BROKERS]) Object.defineProperty(globalThis, BROKERS, { value: new WeakMap() })
|
|
14
|
+
return globalThis[BROKERS]
|
|
15
|
+
}
|
|
16
|
+
async function backend(options) {
|
|
17
|
+
const root = resolveHarnessRoot(options)
|
|
18
|
+
const path = join(root, 'tools/dshx/src/internal/creator-claims.mjs')
|
|
19
|
+
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
20
|
+
return { root, store: await import(`${pathToFileURL(path).href}?sha256=${digest}`) }
|
|
21
|
+
}
|
|
22
|
+
function descendants(agents, owner) {
|
|
23
|
+
if (!owner) return []
|
|
24
|
+
const result = [owner]
|
|
25
|
+
for (let i = 0; i < result.length; i++) for (const agent of agents.list()) {
|
|
26
|
+
if (!result.includes(agent) && agents.isOwnedBy(agent.id, result[i])) result.push(agent)
|
|
27
|
+
}
|
|
28
|
+
return result
|
|
29
|
+
}
|
|
30
|
+
function fenced(broker, agent) {
|
|
31
|
+
const state = broker.store.readClaimState(broker.root)
|
|
32
|
+
if (broker.store.sessionFence(state, agent.id)) return true
|
|
33
|
+
const agents = get(broker.ctx, 'agents')
|
|
34
|
+
return agents.list().some(owner => broker.store.sessionFence(state, owner.id) && descendants(agents, owner).includes(agent))
|
|
35
|
+
}
|
|
36
|
+
function reason(broker, exec) {
|
|
37
|
+
if (!exec.agent || passive.has(exec.name)) return undefined
|
|
38
|
+
try { if (!fenced(broker, exec.agent)) return undefined }
|
|
39
|
+
catch { return 'CREATOR_STATE_UNREADABLE: 无法核验开发权限,请先修复认领记录。' }
|
|
40
|
+
return 'CREATOR_OWNERSHIP_REVOKED: 此开发对话已被接管或正在移交,工具执行已停止。请在接管后的对话继续;重新接手需调用 dshx_request_takeover 并请用户确认。'
|
|
41
|
+
}
|
|
42
|
+
/** Root-lifetime fence intentionally survives preset disposal/HMR. It protects
|
|
43
|
+
* already-loaded old sessions, and is disposed only with the Host's root. */
|
|
44
|
+
export async function ensureTakeoverFence(ctx, options = {}) {
|
|
45
|
+
const host = ctx.root ?? ctx, { root, store } = await backend(options)
|
|
46
|
+
let broker = registry().get(host)
|
|
47
|
+
if (broker) {
|
|
48
|
+
if (broker.root !== root) throw new Error('CREATOR_HOST_ROOT_CHANGED')
|
|
49
|
+
broker.store = store
|
|
50
|
+
broker.evaluate = reason
|
|
51
|
+
return broker
|
|
52
|
+
}
|
|
53
|
+
if (typeof host.tools?.guard !== 'function' || !get(host, 'agents')) throw new Error('CREATOR_TAKEOVER_GUARD_UNAVAILABLE')
|
|
54
|
+
broker = { ctx: host, root, store, active: new Map(), evaluate: reason }
|
|
55
|
+
registry().set(host, broker)
|
|
56
|
+
host.effect(() => {
|
|
57
|
+
const guard = host.tools.guard(exec => broker.evaluate(broker, exec))
|
|
58
|
+
const wrapper = host.on('tools/execute', async (exec, next) => {
|
|
59
|
+
const denied = broker.evaluate(broker, exec)
|
|
60
|
+
if (denied) throw new Error(denied)
|
|
61
|
+
if (!exec.agent) return next()
|
|
62
|
+
const id = exec.agent.id
|
|
63
|
+
broker.active.set(id, (broker.active.get(id) ?? 0) + 1)
|
|
64
|
+
try { return await next() }
|
|
65
|
+
finally { const n = broker.active.get(id) - 1; if (n) broker.active.set(id, n); else broker.active.delete(id) }
|
|
66
|
+
})
|
|
67
|
+
return () => { guard(); wrapper(); registry().delete(host) }
|
|
68
|
+
}, 'Creator ownership fence (Host lifetime)')
|
|
69
|
+
return broker
|
|
70
|
+
}
|
|
71
|
+
export function installTakeoverFence(ctx, options = {}) {
|
|
72
|
+
ctx.effect(async () => { await ensureTakeoverFence(ctx, options) }, 'Creator takeover fence setup')
|
|
73
|
+
}
|
|
74
|
+
function titleOf(session) {
|
|
75
|
+
const events = session?.snapshotEvents?.() ?? []
|
|
76
|
+
for (let i = events.length - 1; i >= 0; i--) if (events[i].type === 'session/title' && typeof events[i].data?.title === 'string') return events[i].data.title
|
|
77
|
+
return '未命名对话'
|
|
78
|
+
}
|
|
79
|
+
function ownedJobs(jobs, agent) { return jobs?.list(agent).filter(job => job.ownerSession === agent.id && liveJob(job)) ?? [] }
|
|
80
|
+
function busy(ctx, agents, broker) {
|
|
81
|
+
const jobs = get(ctx, 'jobs'), terminals = get(ctx, 'terminals')
|
|
82
|
+
return agents.some(agent => agent.status !== 'idle' || broker.active.has(agent.id) || ownedJobs(jobs, agent).length || terminals?.hasOwned(agent))
|
|
83
|
+
}
|
|
84
|
+
async function bounded(promise, signal, ms, timeoutMessage = 'CREATOR_STOP_TIMEOUT: 旧任务尚未停止,原认领保留。') {
|
|
85
|
+
let timer, listener
|
|
86
|
+
try {
|
|
87
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
88
|
+
timer = setTimeout(() => reject(new Error(timeoutMessage)), ms)
|
|
89
|
+
listener = () => reject(new Error('CREATOR_TAKEOVER_CANCELLED'))
|
|
90
|
+
if (signal?.aborted) listener(); else signal?.addEventListener('abort', listener, { once: true })
|
|
91
|
+
})])
|
|
92
|
+
} finally { clearTimeout(timer); signal?.removeEventListener('abort', listener) }
|
|
93
|
+
}
|
|
94
|
+
async function drain(ctx, owner, broker, signal) {
|
|
95
|
+
const agents = get(ctx, 'agents'), jobs = get(ctx, 'jobs'), terminals = get(ctx, 'terminals')
|
|
96
|
+
const seen = new Set()
|
|
97
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
98
|
+
const group = descendants(agents, owner)
|
|
99
|
+
for (const agent of group) { seen.add(agent.id); agent.cancel({ kind: 'user' }) }
|
|
100
|
+
const waits = []
|
|
101
|
+
for (const agent of group) {
|
|
102
|
+
for (const job of ownedJobs(jobs, agent)) {
|
|
103
|
+
jobs.kill(job.id, agent, 'Creator+ user-confirmed takeover')
|
|
104
|
+
waits.push(jobs.wait(job.id, 20_000, agent, signal).then(result => { if (liveJob(result)) throw new Error('CREATOR_BACKGROUND_JOB_STILL_RUNNING') }))
|
|
105
|
+
}
|
|
106
|
+
for (const terminal of terminals?.list(agent) ?? []) waits.push(terminals.kill(agent, terminal.id, 'Creator+ user-confirmed takeover'))
|
|
107
|
+
waits.push(agent.whenIdle())
|
|
108
|
+
}
|
|
109
|
+
await bounded(Promise.all(waits), signal, 25_000)
|
|
110
|
+
const current = descendants(agents, owner)
|
|
111
|
+
if (current.every(agent => seen.has(agent.id)) && !busy(ctx, current, broker)) return [...seen]
|
|
112
|
+
}
|
|
113
|
+
throw new Error('CREATOR_OWNER_NOT_QUIESCENT: 原认领保留,请稍后重试。')
|
|
114
|
+
}
|
|
115
|
+
export async function requestTakeover(ctx, pluginId, exec, options = {}) {
|
|
116
|
+
if (!/^[a-z][a-z0-9-]*$/.test(pluginId)) throw new Error('invalid plugin id')
|
|
117
|
+
const agent = exec?.agent, agents = get(ctx, 'agents'), questions = get(ctx, 'userQuestions')
|
|
118
|
+
if (!agent || agents?.get(agent.id) !== agent || !agents.roots().includes(agent) || typeof questions?.ask !== 'function') throw new Error('CREATOR_HUMAN_CONFIRMATION_UNAVAILABLE')
|
|
119
|
+
const broker = await ensureTakeoverFence(ctx, options), { root, store } = broker
|
|
120
|
+
const priorFence = store.sessionFence(store.readClaimState(root), agent.id)
|
|
121
|
+
if (priorFence && priorFence.pluginId !== pluginId) throw new Error(`CREATOR_SESSION_REVOKED: 此旧对话只能重新申请接管 ${priorFence.pluginId};开发其他插件请使用新对话。`)
|
|
122
|
+
const run = options.runDshx ?? runDshx, hostPort = options.hostPort ?? currentWebPort()
|
|
123
|
+
// External supervisor verifies this exact unique Host before any handoff.
|
|
124
|
+
const watch = await run(['creator', 'watch', '--json'], exec, { ...options, hostPort })
|
|
125
|
+
if (watch.exitCode !== 0) return watch
|
|
126
|
+
const snapshot = store.inspectClaim(root, pluginId)
|
|
127
|
+
if (snapshot.claim?.sessionId === agent.id && !store.sessionFence(store.readClaimState(root), agent.id)) return { exitCode: 0, state: 'ALREADY_OWNED', pluginId }
|
|
128
|
+
const owner = snapshot.claim ? agents.get(snapshot.claim.sessionId) : undefined
|
|
129
|
+
if (snapshot.claim && !owner && (!snapshot.claim.hostPid || store.processAlive(snapshot.claim.hostPid))) throw new Error('CREATOR_OWNER_STATE_UNKNOWN: 无法核实旧持有者已停止。请用外部 dshx creator inspect 查看持有者;未更改认领。')
|
|
130
|
+
if (owner && descendants(agents, owner).includes(agent)) throw new Error('CREATOR_DELEGATED_TAKEOVER_FORBIDDEN')
|
|
131
|
+
const previous = owner?.session ?? get(ctx, 'sessions')?.get(snapshot.claim?.sessionId)
|
|
132
|
+
const group = descendants(agents, owner), running = busy(ctx, group, broker)
|
|
133
|
+
const approve = running ? '停止旧任务并接管' : '接管到当前对话'
|
|
134
|
+
const id = randomUUID(), createdAt = Date.now()
|
|
135
|
+
const questionSignal = exec.signal ? AbortSignal.any([exec.signal, AbortSignal.timeout(300_000)]) : AbortSignal.timeout(300_000)
|
|
136
|
+
const answer = await bounded(questions.ask({ agent, signal: questionSignal, questions: [{
|
|
137
|
+
id, header: '插件接管', question: `将 ${pluginId} 的开发权限移交到当前对话?`,
|
|
138
|
+
detail: `原对话:${snapshot.claim ? titleOf(previous) : '当前无持有者'}\n会话:${snapshot.claim?.sessionId ?? '无'}\n工作区:${previous?.header?.cwd ?? '未记录'}\n最后刷新:${snapshot.claim?.lastSeenAt ?? '无'}\n状态:${running ? '仍有运行中的任务' : '已空闲'}\n\n接管会停止原开发对话及其子任务、后台命令和终端,并阻止该对话继续调用工具。源码和对话记录保留。`,
|
|
139
|
+
options: [{ label: '取消', description: '保持现有认领' }, { label: approve, description: '停止旧开发任务后移交权限' }], multiSelect: false,
|
|
140
|
+
}] }), questionSignal, 300_000, 'CREATOR_CONFIRMATION_EXPIRED: 请重新申请接管。')
|
|
141
|
+
const selected = answer?.answers
|
|
142
|
+
if (!Array.isArray(selected) || selected.length !== 1 || selected[0].id !== id || selected[0].custom || selected[0].selected?.length !== 1 || selected[0].selected[0] !== approve) return { exitCode: 1, state: 'TAKEOVER_CANCELLED', pluginId }
|
|
143
|
+
if (exec.signal?.aborted || Date.now() - createdAt > 300_000) throw new Error('CREATOR_CONFIRMATION_EXPIRED: 请重新申请接管。')
|
|
144
|
+
if (owner && agents.get(owner.id) !== owner) throw new Error('CREATOR_OWNER_CHANGED')
|
|
145
|
+
if (!running && busy(ctx, descendants(agents, owner), broker)) throw new Error('CREATOR_OWNER_STARTED: 旧对话已开始运行,请重新确认停止并接管。')
|
|
146
|
+
const context = { sessionId: agent.id, hostPid: process.pid, hostPort }
|
|
147
|
+
const fencedSessions = [...new Set([...(snapshot.claim ? [snapshot.claim.sessionId] : []), ...descendants(agents, owner).map(item => item.id)])].filter(value => value !== agent.id)
|
|
148
|
+
const grant = store.beginTakeover(root, snapshot, context, fencedSessions, id)
|
|
149
|
+
try {
|
|
150
|
+
const stopped = owner ? await drain(ctx, owner, broker, exec.signal) : fencedSessions
|
|
151
|
+
if (exec.signal?.aborted) throw new Error('CREATOR_TAKEOVER_CANCELLED')
|
|
152
|
+
store.markTakeoverReady(root, grant.id, grant.grant, stopped)
|
|
153
|
+
const result = await run(['creator', 'takeover', pluginId, '--json'], exec, { ...options, hostPort, takeoverGrant: grant })
|
|
154
|
+
if (result.exitCode === 0) rememberCreatorClaim(exec, pluginId)
|
|
155
|
+
return result
|
|
156
|
+
} finally { store.abortTakeover(root, grant.id, grant.grant, 'handoff did not commit') }
|
|
157
|
+
}
|