deepseek-coordination 0.1.1 → 0.1.5
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/README.md +271 -14
- package/README.zh-CN.md +160 -11
- package/client.js +338 -0
- package/cordis.patch.yml +40 -0
- package/package.json +22 -2
- package/src/host/bin/dsh-os.ps1 +10 -1
- package/src/host/bin/dsh-os7.ps1 +16 -1
- package/src/plugin/index.js +4 -213
- package/src/plugin/invoke.js +301 -0
- package/src/plugin/settings.js +340 -0
- package/src/plugin/takeover.js +432 -0
- package/src/plugin/workflow.js +1464 -0
- package/tools/measure-cache.mjs +223 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-os settings service: the state the settings panel edits and the takeover reads.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS AS A SERVICE RATHER THAN A CLOSURE IN takeover.js
|
|
5
|
+
*
|
|
6
|
+
* The switch has to be readable by the interception on every tool call, writable by the HTTP routes the
|
|
7
|
+
* browser panel talks to, and visible to both without either one owning the other. A Cordis service is
|
|
8
|
+
* how that is expressed in this runtime: `takeover.js` injects `dsh-os-settings` and calls `enabled()`
|
|
9
|
+
* on each call, and the routes mutate the same object.
|
|
10
|
+
*
|
|
11
|
+
* WHY THE STATE IS A FILE AND NOT THE PROFILE
|
|
12
|
+
*
|
|
13
|
+
* A toggle a user flips in a panel must survive a restart, and `cordis.patch.yml` is a deployment input
|
|
14
|
+
* rather than runtime state: writing it from a plugin would make the profile's patch layer — the layer
|
|
15
|
+
* a human reviews — change underneath them. The state therefore lives in its own JSON document.
|
|
16
|
+
*
|
|
17
|
+
* LOCATION. `DSH_HOME` is preferred over the package root when it is set, because the package directory
|
|
18
|
+
* is the installed artifact: it is replaced wholesale on upgrade, it may be read-only, and on Windows it
|
|
19
|
+
* commonly sits under a path that an installer owns. A setting that only survives until the next upgrade
|
|
20
|
+
* is worse than no setting. The package root remains the fallback so a checkout works with no
|
|
21
|
+
* environment at all.
|
|
22
|
+
*
|
|
23
|
+
* FAILURE POLICY. A settings file that cannot be read is treated as absent and the documented defaults
|
|
24
|
+
* are used; a settings file that cannot be written fails the HTTP call with a readable reason. Neither
|
|
25
|
+
* case may take the host down: the whole point of the feature is that it is optional.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
29
|
+
import { randomBytes } from 'node:crypto'
|
|
30
|
+
import { dirname, join } from 'node:path'
|
|
31
|
+
|
|
32
|
+
import { PKG_ROOT } from './invoke.js'
|
|
33
|
+
import { TAKEOVER_TOOLS } from './takeover.js'
|
|
34
|
+
|
|
35
|
+
/** Service name other plugins inject. */
|
|
36
|
+
export const SERVICE_NAME = 'dsh-os-settings'
|
|
37
|
+
|
|
38
|
+
/** How many recent activity entries the panel can show. */
|
|
39
|
+
const ACTIVITY_LIMIT = 50
|
|
40
|
+
|
|
41
|
+
/** The shape of every boolean this service stores, so a hand-edited file cannot invert a default. */
|
|
42
|
+
function asBoolean(value, fallback) {
|
|
43
|
+
return typeof value === 'boolean' ? value : fallback
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Normalise an arbitrary parsed document into the settings shape, keeping only known keys. */
|
|
47
|
+
export function normalizeSettings(raw) {
|
|
48
|
+
const source = raw !== null && typeof raw === 'object' ? raw : {}
|
|
49
|
+
const tools = source.tools !== null && typeof source.tools === 'object' ? source.tools : {}
|
|
50
|
+
const normalizedTools = {}
|
|
51
|
+
for (const tool of TAKEOVER_TOOLS) {
|
|
52
|
+
normalizedTools[tool] = asBoolean(tools[tool], true)
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
version: 1,
|
|
56
|
+
// The master switch.
|
|
57
|
+
|
|
58
|
+
enabled: asBoolean(source.enabled, false),
|
|
59
|
+
tools: normalizedTools,
|
|
60
|
+
// 'target' preserves the target file's BOM and line endings; 'builtin' reproduces the built-in
|
|
61
|
+
// UTF-8-without-BOM, LF behaviour exactly. 'target' is the default because it is the reason the
|
|
62
|
+
// takeover exists.
|
|
63
|
+
writeFormat: source.writeFormat === 'builtin' ? 'builtin' : 'target',
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where the settings document lives, with the reason each candidate is in the order it is in. */
|
|
68
|
+
export function settingsPath(env = process.env) {
|
|
69
|
+
const home = env.DSH_HOME
|
|
70
|
+
if (typeof home === 'string' && home.trim() !== '') return join(home, 'dsh-os', 'settings.json')
|
|
71
|
+
return join(PKG_ROOT, '.dsh-os-settings.json')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read the document, or the defaults. Never throws: an unreadable file is an absent one. */
|
|
75
|
+
export function readSettings(file) {
|
|
76
|
+
try {
|
|
77
|
+
if (!existsSync(file)) return normalizeSettings(undefined)
|
|
78
|
+
const raw = readFileSync(file, 'utf8')
|
|
79
|
+
if (raw.trim() === '') return normalizeSettings(undefined)
|
|
80
|
+
return normalizeSettings(JSON.parse(raw))
|
|
81
|
+
} catch {
|
|
82
|
+
return normalizeSettings(undefined)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Write the document atomically: sibling temporary file, then rename.
|
|
88
|
+
*
|
|
89
|
+
* A panel toggle and a tool call can overlap, and a half-written settings file would be read as
|
|
90
|
+
* "defaults" on the next start — silently disabling a feature the user turned on. Rename is atomic on
|
|
91
|
+
* the same filesystem, and the temporary file is a sibling by construction.
|
|
92
|
+
*/
|
|
93
|
+
export function writeSettings(file, settings) {
|
|
94
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
95
|
+
const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`
|
|
96
|
+
try {
|
|
97
|
+
writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
|
|
98
|
+
renameSync(temporary, file)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
try { rmSync(temporary, { force: true }) } catch { /* the original failure is the one that matters */ }
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The live settings object, registered as a Cordis service.
|
|
107
|
+
*
|
|
108
|
+
* `record`/`recordFallback` exist so the panel can show what actually happened rather than only what is
|
|
109
|
+
* configured: "takeover is on" and "takeover is answering calls" are different claims, and only the
|
|
110
|
+
* second one is evidence that the feature works.
|
|
111
|
+
*/
|
|
112
|
+
export class SettingsService {
|
|
113
|
+
constructor(ctx, config = {}) {
|
|
114
|
+
this.ctx = ctx
|
|
115
|
+
this.file = config.settingsPath ?? settingsPath()
|
|
116
|
+
this.settings = readSettings(this.file)
|
|
117
|
+
this.activity = []
|
|
118
|
+
this.listeners = new Set()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The whole document, as the panel renders it. */
|
|
122
|
+
current() {
|
|
123
|
+
return {
|
|
124
|
+
...this.settings,
|
|
125
|
+
path: this.file,
|
|
126
|
+
tools: { ...this.settings.tools },
|
|
127
|
+
activity: [...this.activity],
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether takeover is answering calls at all: the master switch. */
|
|
132
|
+
enabled() {
|
|
133
|
+
return this.settings.enabled === true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether one tool is taken over. Meaningless while the master switch is off, and reported as such. */
|
|
137
|
+
toolEnabled(tool) {
|
|
138
|
+
if (!this.settings.enabled) return false
|
|
139
|
+
return this.settings.tools?.[tool] === true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* How a redirected write should encode its bytes: `'target'` preserves the file's own BOM and line
|
|
144
|
+
* endings, `'builtin'` reproduces the built-in UTF-8-without-BOM, LF behaviour exactly.
|
|
145
|
+
*/
|
|
146
|
+
writeFormat() {
|
|
147
|
+
return this.settings.writeFormat === 'builtin' ? 'builtin' : 'target'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Apply a partial update, persist it, and tell the panel. Returns the new document. */
|
|
151
|
+
update(patch) {
|
|
152
|
+
const next = normalizeSettings({ ...this.settings, ...patch, tools: { ...this.settings.tools, ...(patch.tools ?? {}) } })
|
|
153
|
+
writeSettings(this.file, next)
|
|
154
|
+
this.settings = next
|
|
155
|
+
this.emit()
|
|
156
|
+
return this.current()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Record one intercepted call.
|
|
161
|
+
*
|
|
162
|
+
* Bounded and in-memory on purpose: this is a live activity feed for a panel, not an audit log. The
|
|
163
|
+
* durable trail is `permission.json`'s audit array, written by the consent delegate.
|
|
164
|
+
*/
|
|
165
|
+
record(tool, exec, value) {
|
|
166
|
+
this.push({
|
|
167
|
+
at: new Date().toISOString(),
|
|
168
|
+
tool,
|
|
169
|
+
outcome: 'taken-over',
|
|
170
|
+
// The path or command, which is what makes the feed readable at a glance.
|
|
171
|
+
subject: subjectOf(tool, exec?.arguments),
|
|
172
|
+
bytes: typeof value?.after === 'string' ? Buffer.byteLength(value.after, 'utf8') : undefined,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Record that an interception declined a call and the built-in tool answered it instead. */
|
|
177
|
+
recordFallback(tool, exec, error) {
|
|
178
|
+
this.push({
|
|
179
|
+
at: new Date().toISOString(),
|
|
180
|
+
tool,
|
|
181
|
+
outcome: 'fell-back',
|
|
182
|
+
subject: subjectOf(tool, exec?.arguments),
|
|
183
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
push(entry) {
|
|
188
|
+
this.activity.push(entry)
|
|
189
|
+
if (this.activity.length > ACTIVITY_LIMIT) this.activity.splice(0, this.activity.length - ACTIVITY_LIMIT)
|
|
190
|
+
this.emit()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Subscribe to changes; returns the unsubscribe function, as `ctx.effect` expects. */
|
|
194
|
+
subscribe(listener) {
|
|
195
|
+
this.listeners.add(listener)
|
|
196
|
+
return () => this.listeners.delete(listener)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
emit() {
|
|
200
|
+
for (const listener of this.listeners) {
|
|
201
|
+
try { listener() } catch { /* a panel listener must not break a tool call */ }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The tool-call subject a human would recognise: the file for file tools, the command for pwsh. */
|
|
207
|
+
function subjectOf(tool, args) {
|
|
208
|
+
if (args === null || typeof args !== 'object') return ''
|
|
209
|
+
if (tool === 'pwsh') return typeof args.command === 'string' ? args.command.slice(0, 200) : ''
|
|
210
|
+
return typeof args.file_path === 'string' ? args.file_path : ''
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// HTTP surface for the browser panel
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/** Write one JSON response. Local rather than imported: this package has no runtime dependencies. */
|
|
218
|
+
function writeJson(res, status, value) {
|
|
219
|
+
const body = JSON.stringify(value)
|
|
220
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
221
|
+
res.end(body)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Read one JSON request body with a ceiling, so a malformed caller cannot grow the heap. */
|
|
225
|
+
function readJsonBody(req, limit = 65536) {
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
const chunks = []
|
|
228
|
+
let size = 0
|
|
229
|
+
req.on('data', (chunk) => {
|
|
230
|
+
size += chunk.length
|
|
231
|
+
if (size > limit) {
|
|
232
|
+
reject(new Error('request body too large'))
|
|
233
|
+
req.destroy()
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
chunks.push(chunk)
|
|
237
|
+
})
|
|
238
|
+
req.on('error', reject)
|
|
239
|
+
req.on('end', () => {
|
|
240
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
241
|
+
if (text === '') return resolve({})
|
|
242
|
+
try { resolve(JSON.parse(text)) } catch (error) { reject(new Error(`request body is not JSON: ${error.message}`)) }
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The panel's three endpoints.
|
|
249
|
+
*
|
|
250
|
+
* They are registered through `ctx.webServer`, the same seam the built-in SPA and the other UI plugins
|
|
251
|
+
* use, so they are reachable at the same origin with no port, token or CORS negotiation.
|
|
252
|
+
*/
|
|
253
|
+
export function makeSettingsRoutes(service) {
|
|
254
|
+
const state = () => ({ ok: true, settings: service.current(), available: TAKEOVER_TOOLS })
|
|
255
|
+
return [
|
|
256
|
+
{
|
|
257
|
+
kind: 'exact',
|
|
258
|
+
path: '/api/dsh-os/settings',
|
|
259
|
+
handler: (req, res) => {
|
|
260
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
261
|
+
writeJson(res, 200, state())
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
kind: 'exact',
|
|
266
|
+
path: '/api/dsh-os/set',
|
|
267
|
+
handler: (req, res) => {
|
|
268
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
269
|
+
readJsonBody(req).then((body) => {
|
|
270
|
+
// Only known keys are forwarded: `update` normalises anyway, but refusing an unknown shape here
|
|
271
|
+
// means a typo in the panel is a visible error instead of a silent no-op.
|
|
272
|
+
const patch = {}
|
|
273
|
+
if (typeof body.enabled === 'boolean') patch.enabled = body.enabled
|
|
274
|
+
if (body.writeFormat === 'target' || body.writeFormat === 'builtin') patch.writeFormat = body.writeFormat
|
|
275
|
+
if (body.tools !== null && typeof body.tools === 'object') {
|
|
276
|
+
patch.tools = {}
|
|
277
|
+
for (const tool of TAKEOVER_TOOLS) {
|
|
278
|
+
if (typeof body.tools[tool] === 'boolean') patch.tools[tool] = body.tools[tool]
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (Object.keys(patch).length === 0) {
|
|
282
|
+
writeJson(res, 400, { ok: false, error: 'no recognised setting in the request body' })
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
service.update(patch)
|
|
287
|
+
writeJson(res, 200, state())
|
|
288
|
+
} catch (error) {
|
|
289
|
+
writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
290
|
+
}
|
|
291
|
+
}, (error) => {
|
|
292
|
+
writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
293
|
+
})
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The plugin row for the settings service.
|
|
301
|
+
*
|
|
302
|
+
* There is deliberately NO `inject` here, and that is the whole point of this shape.
|
|
303
|
+
*
|
|
304
|
+
* An `inject: ['webServer']` on the row would gate the row's activation on a service that a profile may
|
|
305
|
+
* legitimately never provide — and the settings service is what the TAKEOVER injects, so a profile
|
|
306
|
+
* without a web server would not merely lose the panel, it would silently lose the interception too.
|
|
307
|
+
* The panel is an interface; the interception is the feature.
|
|
308
|
+
*
|
|
309
|
+
* So the service is provided unconditionally, and the HTTP routes are registered through `ctx.inject`,
|
|
310
|
+
* which runs its callback when (and only when) `webServer` is available and unwinds it if it goes away.
|
|
311
|
+
* A headless deployment therefore keeps a working takeover with no endpoints, and a Web one gets both.
|
|
312
|
+
*/
|
|
313
|
+
export const name = 'dsh-os-settings'
|
|
314
|
+
|
|
315
|
+
export function apply(ctx, config = {}) {
|
|
316
|
+
const service = new SettingsService(ctx, config)
|
|
317
|
+
|
|
318
|
+
// `ctx.provide` REGISTERS the service and returns a disposer; `ctx.set` only overwrites the value of a
|
|
319
|
+
// service that was already provided, and calling it first throws
|
|
320
|
+
// Error: cannot set property "dsh-os-settings" without provide
|
|
321
|
+
// which would abort the whole entry. Same trap the consent delegate documents.
|
|
322
|
+
const disposeService = ctx.provide?.(SERVICE_NAME, service)
|
|
323
|
+
void disposeService
|
|
324
|
+
|
|
325
|
+
// A context without the injection API (the unit tests' stand-in) simply gets no routes, which is
|
|
326
|
+
// honest rather than half-registered.
|
|
327
|
+
if (typeof ctx.inject !== 'function') return
|
|
328
|
+
|
|
329
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
330
|
+
scoped.effect(() => {
|
|
331
|
+
const disposers = makeSettingsRoutes(service).map((route) => scoped.webServer.register(route))
|
|
332
|
+
return () => {
|
|
333
|
+
for (const dispose of disposers) dispose()
|
|
334
|
+
}
|
|
335
|
+
}, 'dsh-os-settings: routes')
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Exported for tests. */
|
|
340
|
+
export const __internals = { normalizeSettings, writeJson, readJsonBody, subjectOf, ACTIVITY_LIMIT }
|