@ran-sh/dsh-crew 0.3.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.
Files changed (85) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +8 -0
  3. package/.mcp.json +8 -0
  4. package/LICENSE +21 -0
  5. package/README.de.md +359 -0
  6. package/README.es.md +359 -0
  7. package/README.fr.md +359 -0
  8. package/README.hi.md +359 -0
  9. package/README.id.md +359 -0
  10. package/README.ja.md +359 -0
  11. package/README.ko.md +359 -0
  12. package/README.md +360 -0
  13. package/README.pt.md +359 -0
  14. package/README.ru.md +359 -0
  15. package/README.th.md +359 -0
  16. package/README.tr.md +359 -0
  17. package/README.vi.md +359 -0
  18. package/README.zh-TW.md +359 -0
  19. package/README.zh.md +305 -0
  20. package/agents/ds-flash.md +26 -0
  21. package/agents/ds-pro.md +32 -0
  22. package/agents/ds-reviewer.md +23 -0
  23. package/agents/ds-worker.md +22 -0
  24. package/codex/agents/ds-flash.toml +30 -0
  25. package/codex/agents/ds-pro.toml +31 -0
  26. package/codex/agents/ds-reviewer.toml +28 -0
  27. package/codex/agents/ds-worker.toml +28 -0
  28. package/codex/prompts/dsh-config.md +3 -0
  29. package/codex/prompts/dsh-status.md +1 -0
  30. package/commands/config.md +11 -0
  31. package/commands/off.md +5 -0
  32. package/commands/on.md +5 -0
  33. package/commands/status.md +5 -0
  34. package/cordis.patch.yml +4 -0
  35. package/docs/images/dsh-crew-host.png +0 -0
  36. package/docs/images/dsh-crew-jobs.png +0 -0
  37. package/docs/images/dsh-crew-logo.png +0 -0
  38. package/docs/images/dsh-crew-overview.png +0 -0
  39. package/lib/client.js +2765 -0
  40. package/package.json +125 -0
  41. package/scripts/build-client.mjs +28 -0
  42. package/scripts/live-crew-smoke.mjs +39 -0
  43. package/scripts/live-policy-matrix.mjs +177 -0
  44. package/scripts/policy-probe.mjs +101 -0
  45. package/scripts/setup.mjs +294 -0
  46. package/scripts/smoke-real.mjs +110 -0
  47. package/scripts/smoke.mjs +78 -0
  48. package/scripts/verify-installer-fix.mjs +26 -0
  49. package/src/adaptive-routing.mjs +260 -0
  50. package/src/client/activation-summary.tsx +64 -0
  51. package/src/client/entry.tsx +236 -0
  52. package/src/client/index.tsx +1120 -0
  53. package/src/config-readiness.mjs +59 -0
  54. package/src/delivery.mjs +205 -0
  55. package/src/dsh-cli-runtime.mjs +251 -0
  56. package/src/failure-classification.mjs +172 -0
  57. package/src/hub/entry.mjs +98 -0
  58. package/src/hub/index.mjs +757 -0
  59. package/src/hub-client.mjs +132 -0
  60. package/src/hub-compatibility.mjs +49 -0
  61. package/src/i18n.mjs +19 -0
  62. package/src/install/cli.mjs +28 -0
  63. package/src/install/install-legacy.mjs +460 -0
  64. package/src/install/install.mjs +451 -0
  65. package/src/jobs.mjs +275 -0
  66. package/src/mcp-runtime.mjs +257 -0
  67. package/src/model-catalog.mjs +173 -0
  68. package/src/model-routing.mjs +391 -0
  69. package/src/multimodal.mjs +0 -0
  70. package/src/policy-legacy.mjs +830 -0
  71. package/src/policy.mjs +197 -0
  72. package/src/readiness-matrix.mjs +169 -0
  73. package/src/runtime-controls.mjs +90 -0
  74. package/src/runtime-identity.mjs +108 -0
  75. package/src/server.mjs +477 -0
  76. package/src/status-shard.mjs +52 -0
  77. package/src/structured-error-code.mjs +39 -0
  78. package/src/vision-route.mjs +138 -0
  79. package/src/workflow-runtime.mjs +567 -0
  80. package/src/workflow.mjs +160 -0
  81. package/src/workspace-audit.mjs +231 -0
  82. package/src/workspace-isolation.mjs +306 -0
  83. package/statusline/statusline.sh +14 -0
  84. package/statusline/worker-segment.sh +35 -0
  85. package/worker.cordis.yml +77 -0
@@ -0,0 +1,757 @@
1
+ // DSH host plugin: workers-hub.
2
+ // Runs DeepSeek workers as first-class in-host agent sessions (visible in the
3
+ // Web UI session list), exposes a loopback jobs API for the CC/Codex MCP shim,
4
+ // and serves the one-click installer endpoints for the settings page.
5
+
6
+ import { randomUUID } from 'node:crypto';
7
+ import { join, isAbsolute } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+ import { createShardWriter, readMergedStatus } from '../status-shard.mjs';
10
+ import {
11
+ normalizeGlobalConfig,
12
+ chooseDefaultTier,
13
+ normalizeWorkerProviderMode,
14
+ getMultimodalRegistrationPlan,
15
+ canDispatchRole,
16
+ resolveModelPolicy,
17
+ resolveRoleTierHint,
18
+ } from '../policy.mjs';
19
+ import { buildDirectSelectionTrace, resolveWorkerModel, resolveModel } from '../model-routing.mjs';
20
+ import { readHarnessModelCatalog } from '../model-catalog.mjs';
21
+ import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
22
+ import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
23
+ import { buildOutcome, JOB_PHASES } from '../workflow.mjs';
24
+ import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
25
+
26
+ // policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
27
+ // here is safe for the profile-realm discipline: it never pulls in package
28
+ // copies that would duplicate module realms.
29
+
30
+ // No @deepseek-ai imports here on purpose: this plugin is loaded into the
31
+ // profile realm, and importing our own package copies would create duplicate
32
+ // module realms (symbol identity mismatches in the tool runtime). Everything
33
+ // below is either plain data or inlined logic that only touches ctx APIs.
34
+
35
+ /** Inlined from @deepseek-ai/dsh-agent model-selection.ts (same semantics). */
36
+ function installModelSelection(agentCtx, selection) {
37
+ agentCtx.on('system-prompt/assemble', async (_assembly, _cause, next) => {
38
+ const selected = selection.current;
39
+ const assembled = await next();
40
+ selection.assembled = selected;
41
+ if (selected === undefined) return assembled;
42
+ return { ...assembled, variables: { ...assembled.variables, provider: selected.provider, model: selected.model } };
43
+ });
44
+ agentCtx.on('agent/request', async (_payload, next) => {
45
+ const resolved = await next();
46
+ const selected = selection.assembled;
47
+ if (selected === undefined) return resolved;
48
+ const { reasoningEffort: _inherited, ...rest } = resolved;
49
+ return {
50
+ ...rest, provider: selected.provider, model: selected.model,
51
+ ...(selected.reasoningEffort === undefined ? {} : { reasoningEffort: selected.reasoningEffort }),
52
+ };
53
+ });
54
+ }
55
+
56
+ /** Inlined shape of createUserMessage (role + fresh id, frozen). */
57
+ function userMessage(text) {
58
+ return Object.freeze({
59
+ id: randomUUID(),
60
+ role: 'user',
61
+ content: [{ type: 'text', text }],
62
+ source: { kind: 'user' },
63
+ });
64
+ }
65
+
66
+ export const name = 'dsh-crew';
67
+ export const inject = ['agents', 'sessions', 'agentDefaultModel', 'tools', 'llm', 'attachments'];
68
+
69
+ // Optional: the host's durable locale preference seeds server-side strings
70
+ // before the panel is ever opened. Absent preference means "browser decides".
71
+ function seedLangFromHost(ctx) {
72
+ try {
73
+ const pref = ctx.settings?.get?.('locale')?.preference;
74
+ if (pref) setLang(pref);
75
+ } catch { /* settings service absent or shaped differently — keep the default */ }
76
+ }
77
+
78
+ import { setLang } from '../i18n.mjs';
79
+
80
+ const ROUTE_BASE = '/_dsh/dsh-crew';
81
+ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro' };
82
+ // Local copy (the hub must not import jobs.mjs, which pulls the DSH SDK into
83
+ // the profile realm): a valid dispatch role set.
84
+ const ROLES = { worker: true, reviewer: true };
85
+ const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
86
+
87
+ // ---------- job registry ----------
88
+
89
+ // Exported for the unit tests (test/hub-windows.test.mjs); instantiation
90
+ // needs only a duck-typed ctx, so spawn()'s path guard is testable without a
91
+ // live DSH host.
92
+ export class WorkerRegistry { constructor(ctx) {
93
+ this.ctx = ctx;
94
+ this.jobs = new Map();
95
+ this.nextId = 1;
96
+ this.shard = createShardWriter('hub');
97
+ }
98
+
99
+ view(job, withResult = false) {
100
+ const v = {
101
+ id: job.id, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
102
+ tier: job.tier, provider: job.provider, model: job.model,
103
+ selection_source: job.selection_source,
104
+ selection_trace: job.selection_trace ?? null,
105
+ effort: job.effort, requested_effort: job.effort, reasoning_effort: job.reasoning_effort ?? null,
106
+ status: job.status, source: job.source, task: job.task.slice(0, 300),
107
+ cwd: job.cwd, turn: job.turn, step: job.step, currentTool: job.currentTool,
108
+ phase: job.phase ?? null,
109
+ toolCalls: job.toolCalls, tokens: job.tokens, mode: 'hub',
110
+ startedAt: job.startedAt, endedAt: job.endedAt,
111
+ delivery_complete: !!job.delivery_complete,
112
+ workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
113
+ };
114
+ if (withResult) {
115
+ v.result = job.result; v.error = job.error; v.stopReason = job.stopReason;
116
+ v.reasonDetail = job.reasonDetail;
117
+ v.delivery = job.delivery_metadata ?? null;
118
+ v.delivery_missing = job.delivery_missing ?? [];
119
+ v.outcome = job.outcome ?? null;
120
+ v.workspace_diff = job.workspaceDiff ?? null;
121
+ v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
122
+ }
123
+ return v;
124
+ }
125
+
126
+ publish() {
127
+ this.shard.publish([...this.jobs.values()].map((j) => this.view(j)));
128
+ }
129
+
130
+ /**
131
+ * Spawn a Hub worker. DeepSeek Official preserves the standalone-compatible
132
+ * tier slots; follow-dsh resolves an ordered provider/model selection from
133
+ * the live Harness catalog. Callers cannot route around either policy.
134
+ *
135
+ * `role` (worker | reviewer) records who does the work; `tier` remains the
136
+ * legacy model-class slot. Reviewer-role jobs always use the pro slot.
137
+ */
138
+ async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding' }) {
139
+ // role is only honored when the caller explicitly names it; a legacy
140
+ // tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
141
+ const hasRole = role === 'worker' || role === 'reviewer';
142
+ if (role !== undefined && !hasRole) throw new Error(`unknown role "${role}"`);
143
+ const effRole = hasRole ? role : 'worker';
144
+ // A reviewer is a role, not a model class: its tier slot is always pro.
145
+ const effTier = role === 'reviewer' ? 'pro' : tier;
146
+ const legacyModel = LEGACY_TIER_MODELS[effTier];
147
+ if (!legacyModel) throw new Error(`unknown tier "${effTier}"`);
148
+ if (!['off', 'high', 'max'].includes(effort)) throw new Error(`unknown effort "${effort}"`);
149
+ // isAbsolute covers POSIX (/...) and Windows drive paths (D:\... / D:/...):
150
+ // on Windows the MCP shim always passes process.cwd() in drive form.
151
+ if (!cwd || !isAbsolute(cwd)) throw new Error('cwd must be an absolute path');
152
+ await this.ctx.get('loader')?.await();
153
+
154
+ // Provider routing: follow-dsh reads the DSH Models selection live; the
155
+ // default deepseek-official keeps legacy setups unchanged. Re-resolved on
156
+ // every spawn so a Models change takes effect on the next worker.
157
+ const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
158
+ const workerProviderMode = normalizeWorkerProviderMode(cfg.worker_provider_mode);
159
+ const getCurrentSelection = () => this.ctx.get('agentDefaultModel')?.currentSelection?.();
160
+ let selection;
161
+ if (workerProviderMode === 'deepseek-official') {
162
+ selection = {
163
+ ok: true,
164
+ provider: 'deepseek-official',
165
+ model: legacyModel,
166
+ source: 'legacy-strict',
167
+ reasoningEffort: effort,
168
+ selection_trace: buildDirectSelectionTrace({
169
+ role: effRole,
170
+ logicalAttempt: attempt,
171
+ modelClassHint: effTier,
172
+ strategy: 'legacy-strict',
173
+ candidateSet: attempt > 0 ? 'escalation' : 'primary',
174
+ provider: 'deepseek-official',
175
+ model: legacyModel,
176
+ source: 'legacy-strict',
177
+ }),
178
+ };
179
+ } else {
180
+ let catalog;
181
+ try {
182
+ catalog = await readHarnessModelCatalog({
183
+ llm: this.ctx.llm ?? this.ctx.get('llm'),
184
+ getCurrentSelection,
185
+ });
186
+ } catch {
187
+ // Older/mocked hosts without the catalog surface can still route the
188
+ // current Harness default without exposing any credential fields.
189
+ const current = getCurrentSelection();
190
+ catalog = current?.provider
191
+ ? { providers: [{ id: current.provider, name: current.provider, models: [] }], harness_default: current }
192
+ : { providers: [], harness_default: null };
193
+ }
194
+ // v0.2 role-based selection only when the caller named a role; legacy
195
+ // tier-only spawns resolve through the tier resolver exactly as before.
196
+ if (hasRole) {
197
+ const policy = resolveModelPolicy(cfg, effRole, { attempt });
198
+ selection = resolveModel({
199
+ role: effRole,
200
+ attempt,
201
+ policy,
202
+ catalog,
203
+ harnessDefault: catalog.harness_default ?? getCurrentSelection(),
204
+ });
205
+ } else {
206
+ selection = resolveWorkerModel({
207
+ tier: effTier,
208
+ priority: cfg[`${effTier}_model_priority`],
209
+ priorityConfigured: cfg[`${effTier}_model_priority_configured`],
210
+ fallback: cfg[`${effTier}_model_fallback`],
211
+ catalog,
212
+ harnessDefault: catalog.harness_default ?? getCurrentSelection(),
213
+ traceContext: {
214
+ role: effRole,
215
+ logicalAttempt: attempt,
216
+ modelClassHint: effTier,
217
+ strategy: 'legacy-tier',
218
+ candidateSet: attempt > 0 ? 'escalation' : 'primary',
219
+ },
220
+ });
221
+ }
222
+ if (!selection.ok) throw Object.assign(new Error(selection.message), { policyCode: selection.code });
223
+ }
224
+
225
+ // The worker always gets the auditable Delivery Contract appended (unless
226
+ // it already carries one), so its final message follows ## Diff / ## Tests
227
+ // / ## Risks — or the review contract for reviewer-role jobs.
228
+ const jobRole = hasRole ? role : (delivery === 'review' || role === 'reviewer' ? 'reviewer' : 'worker');
229
+ const workerPrompt = appendDeliveryInstructions(task, { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
230
+
231
+ const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
232
+ const sessionId = `session-${randomUUID()}`;
233
+ const job = {
234
+ id, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
235
+ selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
236
+ effort, reasoning_effort: selection.reasoningEffort,
237
+ task, source, cwd,
238
+ prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
239
+ phase: JOB_PHASES.RUNNING,
240
+ status: 'running', turn: 0, step: 0, currentTool: null, toolCalls: 0,
241
+ tokens: { input: 0, output: 0, reasoning: 0 },
242
+ startedAt: new Date().toISOString(), endedAt: null,
243
+ result: null, error: null, stopReason: null, handle: null, waiters: [],
244
+ delivery_complete: false, delivery_missing: [], delivery_metadata: null,
245
+ outcome: null,
246
+ texts: [],
247
+ };
248
+ // Read-only pre-run snapshot (async, never blocks dispatch): the audit
249
+ // only needs the before-state by the time the worker finishes. Non-repos
250
+ // degrade to { kind:'no-git' } instead of failing the job.
251
+ job.baseline = await captureWorkspaceBaseline({ cwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
252
+ this.jobs.set(id, job);
253
+
254
+ const presets = this.ctx.get('agentPresets');
255
+ const wanted = preset ?? (tier === 'flash' ? cfg.preset_flash : cfg.preset_pro);
256
+ const presetId = presets === undefined
257
+ ? undefined
258
+ : (await presets.resolve(!wanted || wanted === 'default' ? undefined : wanted)).id;
259
+
260
+ const onEvent = (session, event) => {
261
+ switch (event.type) {
262
+ case 'step/start':
263
+ job.turn = event.data?.turn ?? job.turn;
264
+ job.step = event.data?.step ?? job.step;
265
+ break;
266
+ case 'tool/call': job.currentTool = event.data?.name ?? null; job.toolCalls += 1; break;
267
+ case 'tool/result': job.currentTool = null; break;
268
+ case 'assistant/message': {
269
+ const u = event.data?.usage;
270
+ if (u) {
271
+ job.tokens.input += u.inputTokens ?? 0;
272
+ job.tokens.output += u.outputTokens ?? 0;
273
+ job.tokens.reasoning += u.reasoningTokens ?? 0;
274
+ }
275
+ const text = (event.data?.message?.content ?? [])
276
+ .filter((c) => c?.type === 'text').map((c) => c.text).join('');
277
+ if (text) job.texts.push(text);
278
+ break;
279
+ }
280
+ case 'turn/end':
281
+ job.stopReason = event.data?.reason?.kind ?? null;
282
+ job.reasonDetail = event.data?.reason;
283
+ break;
284
+ }
285
+ this.publish();
286
+ };
287
+
288
+ const run = async () => {
289
+ const handle = await this.ctx.agents.create({
290
+ sessionId,
291
+ meta: { cwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
292
+ agentOptions: { provider: selection.provider, model: selection.model },
293
+ setup: async (agentCtx) => {
294
+ installModelSelection(agentCtx, { current: selection, assembled: undefined });
295
+ if (presets !== undefined) await presets.mount(agentCtx, presetId);
296
+ agentCtx.on('session/event', onEvent);
297
+ agentCtx.on('agent/error', (payload) => {
298
+ const err = payload?.error;
299
+ job.error = err?.message ?? String(err);
300
+ });
301
+ },
302
+ });
303
+ job.handle = handle;
304
+ try {
305
+ // Group the worker session under the workspace of its cwd; create the
306
+ // workspace when none exists yet (resolveByPath is exact-match, so a
307
+ // job cwd never inherits a parent directory's workspace).
308
+ const registry = this.ctx.get('workspaceRegistry');
309
+ if (registry !== undefined) {
310
+ const ws = (await registry.resolveByPath(cwd)) ?? (await registry.create(cwd));
311
+ await ws.attachSession(sessionId);
312
+ }
313
+ } catch (err) {
314
+ this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${cwd}: ${err?.message ?? err}`);
315
+ }
316
+ await handle.agent.whenIdle();
317
+ handle.agent.followup(userMessage(job.prompt));
318
+ await handle.agent.whenIdle();
319
+ job.result = job.texts.at(-1) ?? '';
320
+ job.status = job.stopReason === 'completed' ? 'done' : 'failed';
321
+ if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
322
+ await this.ctx.sessions.flush(handle.agent.session);
323
+ };
324
+
325
+ job.promise = run()
326
+ .catch((err) => {
327
+ if (job.status === 'running') job.status = 'failed';
328
+ job.error = job.error ?? (err?.message ?? String(err));
329
+ })
330
+ .finally(async () => {
331
+ job.endedAt = new Date().toISOString();
332
+ job.currentTool = null;
333
+ // Delivery completeness is separate from execution status: a job can
334
+ // be done yet fail to report Diff/Tests/Risks (or Review sections for
335
+ // an automatic review). Parse whatever final message the worker
336
+ // produced so the orchestrator can decide whether to accept.
337
+ const parsed = parseDeliveryReport(job.result ?? '');
338
+ job.delivery_complete = parsed.complete;
339
+ job.delivery_missing = parsed.missing;
340
+ job.delivery_metadata = formatDeliveryMetadata(parsed);
341
+ // Canonical structured outcome (shared workflow layer) + terminal phase.
342
+ job.outcome = buildOutcome({
343
+ result: job.result ?? '',
344
+ deliveryMeta: job.delivery_metadata,
345
+ executionStatus: job.status === 'done' ? 'completed' : 'failed',
346
+ stopReason: job.stopReason,
347
+ deliveryMissing: job.delivery_missing,
348
+ });
349
+ job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
350
+ // Read-only after-snapshot of the workspace: bounded, redacted patch.
351
+ job.workspaceDiff = job.baseline.kind === 'git'
352
+ ? await captureWorkspaceDiff({ cwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
353
+ : job.baseline;
354
+ this.publish();
355
+ for (const w of job.waiters.splice(0)) w();
356
+ });
357
+
358
+ this.publish();
359
+ return job;
360
+ }
361
+
362
+ async wait(id, timeoutMs) {
363
+ const job = this.jobs.get(id);
364
+ if (!job) return undefined;
365
+ if (job.status !== 'running') return job;
366
+ await Promise.race([
367
+ new Promise((res) => job.waiters.push(res)),
368
+ timeoutMs > 0 ? new Promise((res) => setTimeout(res, timeoutMs)) : Promise.resolve(),
369
+ ]);
370
+ return job;
371
+ }
372
+
373
+ async cancel(id) {
374
+ const job = this.jobs.get(id);
375
+ if (!job) return undefined;
376
+ if (job.status === 'running') {
377
+ job.status = 'cancelled';
378
+ job.phase = JOB_PHASES.CANCELLED;
379
+ job.error = 'cancelled by request';
380
+ try { await job.handle?.dispose(); } catch {}
381
+ this.publish();
382
+ }
383
+ return job;
384
+ }
385
+
386
+ async dispose() {
387
+ for (const job of this.jobs.values()) {
388
+ if (job.status === 'running') { try { await job.handle?.dispose(); } catch {} }
389
+ }
390
+ }
391
+ }
392
+
393
+ // ---------- loopback route helpers (pattern from dsh-noema) ----------
394
+
395
+ function isIpv4Loopback(a) { return /^127(\.\d{1,3}){3}$/.test(a); }
396
+ function isLoopbackAddress(address) {
397
+ if (!address) return false;
398
+ const n = address.toLowerCase().split('%', 1)[0];
399
+ if (n === '::1' || isIpv4Loopback(n)) return true;
400
+ if (!n.startsWith('::ffff:')) return false;
401
+ return isIpv4Loopback(n.slice(7));
402
+ }
403
+ function isLoopbackRequest(req) {
404
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
405
+ const host = (req.headers.host ?? '').split(':')[0].toLowerCase();
406
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || isIpv4Loopback(host);
407
+ }
408
+ function sendJson(res, status, value, headers = {}) {
409
+ const body = JSON.stringify(value);
410
+ res.writeHead(status, {
411
+ 'content-type': 'application/json; charset=utf-8',
412
+ 'content-length': Buffer.byteLength(body),
413
+ 'cache-control': 'no-store',
414
+ 'x-content-type-options': 'nosniff',
415
+ 'cross-origin-resource-policy': 'same-origin',
416
+ ...headers,
417
+ });
418
+ res.end(body);
419
+ }
420
+ /**
421
+ * The panel is the authority on the active locale (DSH's setting may be unset,
422
+ * leaving it to the browser), so it tags requests with ?lang= / body.lang and
423
+ * the hub adopts it. Conversation-side paths with no request behind them —
424
+ * pasted-image transcription — then follow the same language.
425
+ */
426
+ function adoptLang(value) {
427
+ if (value === 'zh' || value === 'en') setLang(value);
428
+ }
429
+
430
+ async function readBody(req, limit = 64 * 1024) {
431
+ let body = '';
432
+ for await (const chunk of req) {
433
+ body += chunk;
434
+ if (body.length > limit) throw new Error('payload too large');
435
+ }
436
+ return body.trim() === '' ? {} : JSON.parse(body);
437
+ }
438
+
439
+ /**
440
+ * Resolve a direct-hub spawn payload against the global policy.
441
+ * Pure helper (no ctx / no I/O) so the jobs route and the unit tests share
442
+ * the exact same normalization: the tier that finally reaches the worker
443
+ * runtime is always the policy resolver's effective tier, never a raw or
444
+ * missing request field.
445
+ *
446
+ * Returns { ok: true, payload } (payload.tier = effective tier) or
447
+ * { ok: false, code, error }.
448
+ */
449
+ export function resolveHubSpawnPayload(payload, getConfig = () => ({})) {
450
+ const config = normalizeGlobalConfig(getConfig());
451
+ const raw = payload ?? {};
452
+ // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
453
+ // and the tier slot is derived from the role (reviewer always → pro).
454
+ if (raw.role === 'worker' || raw.role === 'reviewer') {
455
+ const hint = resolveRoleTierHint(raw.role, raw.tier);
456
+ if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
457
+ const decision = canDispatchRole(config, raw.role, true, {});
458
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
459
+ return { ok: true, payload: { ...raw, role: raw.role, tier: hint.tier } };
460
+ }
461
+ const decision = chooseDefaultTier(config, raw.tier, {});
462
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
463
+ return { ok: true, payload: { ...raw, tier: decision.tier } };
464
+ }
465
+
466
+ // ---------- plugin entry ----------
467
+
468
+ export async function apply(ctx) {
469
+ seedLangFromHost(ctx);
470
+ const hub = new WorkerRegistry(ctx);
471
+ try {
472
+ const { readGlobalConfig } = await import('../install/install.mjs');
473
+ hub.getConfig = () => readGlobalConfig();
474
+ } catch {}
475
+ // Backend policy enforcement for the hub jobs route: mirrors the MCP
476
+ // server's resolver. No session scope exists here (the MCP layer already
477
+ // enforced its own), so the check uses the global config only. The route
478
+ // calls resolveHubSpawnPayload (above) to stamp the effective tier onto
479
+ // the spawn payload.
480
+ const disposers = [];
481
+
482
+ // Multimodal bridge: register describe_image / generate_image for the DS
483
+ // model. Config is read per call so settings-page edits apply live; the
484
+ // capability switches (vision_enabled / imagegen_enabled) decide which tools
485
+ // are registered at plugin boot, so toggling them takes effect on restart.
486
+ try {
487
+ const { createMultimodalTools } = await import('../multimodal.mjs');
488
+ const { readGlobalConfig } = await import('../install/install.mjs');
489
+ const plan = getMultimodalRegistrationPlan(normalizeGlobalConfig(readGlobalConfig()));
490
+ for (const tool of createMultimodalTools(() => readGlobalConfig())) {
491
+ if (!plan.tools[tool.name]) {
492
+ ctx.logger?.info?.(`dsh-crew: ${tool.name} not registered (disabled by capability switch)`);
493
+ continue;
494
+ }
495
+ disposers.push(ctx.effect(() => ctx.tools.register(tool), `dsh-crew: ${tool.name} tool`));
496
+ }
497
+ } catch (err) {
498
+ ctx.logger?.warn?.(`dsh-crew: multimodal tools unavailable: ${err?.message ?? err}`);
499
+ }
500
+
501
+ // Vision route: image paste on the text-only DS models (admission adapter +
502
+ // pre-step transcription). Installed only while Crew Vision is enabled.
503
+ try {
504
+ const { installVisionRoute } = await import('../vision-route.mjs');
505
+ const { readGlobalConfig } = await import('../install/install.mjs');
506
+ if (getMultimodalRegistrationPlan(normalizeGlobalConfig(readGlobalConfig())).visionRoute) {
507
+ disposers.push(installVisionRoute(ctx, () => readGlobalConfig()));
508
+ } else {
509
+ ctx.logger?.info?.('dsh-crew: vision route not installed (Crew Vision disabled by capability switch)');
510
+ }
511
+ } catch (err) {
512
+ ctx.logger?.warn?.(`dsh-crew: vision route unavailable: ${err?.message ?? err}`);
513
+ }
514
+
515
+ ctx.inject(['webServer'], (webCtx) => {
516
+ const webServer = webCtx.webServer;
517
+ const disposers = [];
518
+
519
+ disposers.push(webServer.register({
520
+ kind: 'exact', path: `${ROUTE_BASE}/ping`,
521
+ handler: (req, res) => sendJson(res, 200, { ok: true, service: 'dsh-crew-hub' }),
522
+ }));
523
+
524
+ disposers.push(webServer.register({
525
+ kind: 'prefix', path: `${ROUTE_BASE}/jobs`,
526
+ handler: async (req, res) => {
527
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
528
+ try {
529
+ const url = new URL(req.url, 'http://localhost');
530
+ const parts = url.pathname.slice(`${ROUTE_BASE}/jobs`.length).split('/').filter(Boolean);
531
+ if (req.method === 'GET' && parts.length === 0) {
532
+ // Machine-wide view: this hub's live jobs plus fresh shards from
533
+ // other writers (standalone MCP sessions, other hub instances).
534
+ const own = [...hub.jobs.values()].map((j) => ({ ...hub.view(j), origin: hub.shard.writer }));
535
+ const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
536
+ return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
537
+ }
538
+ if (req.method === 'GET' && parts.length === 1) {
539
+ const wait = Number(url.searchParams.get('wait') ?? 0);
540
+ const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
541
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
542
+ return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
543
+ }
544
+ if (req.method === 'POST' && parts.length === 0) {
545
+ const payload = await readBody(req);
546
+ // Same policy resolver as the MCP server (src/server.mjs), with the
547
+ // resolved effective tier stamped back onto the spawn payload: a
548
+ // missing or policy-clamped tier must never reach WorkerRegistry
549
+ // as its raw default (pro-only + no tier used to spawn flash).
550
+ const resolved = resolveHubSpawnPayload(payload, () => hub.getConfig?.() ?? {});
551
+ if (!resolved.ok) return sendJson(res, 400, { ok: false, error: resolved.error, code: resolved.code });
552
+ const job = await hub.spawn(resolved.payload);
553
+ return sendJson(res, 200, { ok: true, job: hub.view(job) });
554
+ }
555
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'cancel') {
556
+ const job = await hub.cancel(parts[0]);
557
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
558
+ return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
559
+ }
560
+ return sendJson(res, 404, { ok: false, error: 'unknown jobs endpoint' });
561
+ } catch (err) {
562
+ const code = boundedMachineCodeFromError(err);
563
+ const body = { ok: false, error: err?.message ?? String(err) };
564
+ if (code) body.code = code;
565
+ return sendJson(res, 400, body);
566
+ }
567
+ },
568
+ }));
569
+
570
+ disposers.push(webServer.register({
571
+ kind: 'exact', path: `${ROUTE_BASE}/config`,
572
+ handler: async (req, res) => {
573
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
574
+ try {
575
+ const { readGlobalConfig, writeGlobalConfig } = await import(`../install/install.mjs?t=${Date.now()}`);
576
+ if (req.method === 'GET') {
577
+ adoptLang(new URL(req.url, 'http://localhost').searchParams.get('lang'));
578
+ return sendJson(res, 200, { ok: true, config: readGlobalConfig() });
579
+ }
580
+ if (req.method === 'POST') return sendJson(res, 200, { ok: true, config: writeGlobalConfig(await readBody(req)) });
581
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
582
+ } catch (err) {
583
+ return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
584
+ }
585
+ },
586
+ }));
587
+
588
+ disposers.push(webServer.register({
589
+ kind: 'exact', path: `${ROUTE_BASE}/presets`,
590
+ handler: async (req, res) => {
591
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
592
+ try {
593
+ const presets = ctx.get('agentPresets');
594
+ if (presets === undefined) return sendJson(res, 200, { ok: true, presets: [] });
595
+ const list = await presets.list();
596
+ return sendJson(res, 200, {
597
+ ok: true,
598
+ defaultId: presets.defaultId,
599
+ presets: list.map((p) => ({ id: p.id, name: p.name ?? p.id })),
600
+ });
601
+ } catch (err) {
602
+ return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
603
+ }
604
+ },
605
+ }));
606
+
607
+ disposers.push(webServer.register({
608
+ kind: 'exact', path: `${ROUTE_BASE}/provider`,
609
+ handler: async (req, res) => {
610
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
611
+ try {
612
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
613
+ const mode = normalizeWorkerProviderMode(config.worker_provider_mode);
614
+ let selections;
615
+ if (mode === 'deepseek-official') {
616
+ selections = {
617
+ flash: { provider: 'deepseek-official', model: 'deepseek-v4-flash', source: 'legacy-strict' },
618
+ pro: { provider: 'deepseek-official', model: 'deepseek-v4-pro', source: 'legacy-strict' },
619
+ };
620
+ } else {
621
+ const catalog = await readHarnessModelCatalog({
622
+ llm: ctx.llm ?? ctx.get('llm'),
623
+ getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
624
+ });
625
+ selections = {};
626
+ for (const tier of ['flash', 'pro']) {
627
+ const selected = resolveWorkerModel({
628
+ tier,
629
+ priority: config[`${tier}_model_priority`],
630
+ priorityConfigured: config[`${tier}_model_priority_configured`],
631
+ fallback: config[`${tier}_model_fallback`],
632
+ catalog,
633
+ harnessDefault: catalog.harness_default,
634
+ });
635
+ selections[tier] = selected.ok
636
+ ? { provider: selected.provider, model: selected.model, source: selected.source }
637
+ : { code: selected.code, error: selected.message };
638
+ }
639
+ }
640
+ return sendJson(res, 200, {
641
+ ok: true,
642
+ worker_provider_mode: mode,
643
+ effective_worker_provider: selections.flash?.provider ?? null,
644
+ effective_worker_selection: selections,
645
+ });
646
+ } catch (err) {
647
+ return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE', error: 'Unable to resolve Harness worker models.' });
648
+ }
649
+ },
650
+ }));
651
+
652
+ disposers.push(webServer.register({
653
+ kind: 'exact', path: `${ROUTE_BASE}/models`,
654
+ handler: async (req, res) => {
655
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
656
+ if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
657
+ try {
658
+ const catalog = await readHarnessModelCatalog({
659
+ llm: ctx.llm ?? ctx.get('llm'),
660
+ getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
661
+ });
662
+ return sendJson(res, 200, { ok: true, ...catalog });
663
+ } catch (error) {
664
+ return sendJson(res, 503, { ok: false, code: error?.code ?? 'MODEL_CATALOG_UNAVAILABLE', error: 'Unable to read Harness model catalog.' });
665
+ }
666
+ },
667
+ }));
668
+
669
+ disposers.push(webServer.register({
670
+ kind: 'exact', path: `${ROUTE_BASE}/vision-models`,
671
+ handler: async (req, res) => {
672
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
673
+ try {
674
+ const url = new URL(req.url, 'http://localhost');
675
+ const provider = url.searchParams.get('provider');
676
+ const force = url.searchParams.get('refresh') === '1';
677
+ const lang = url.searchParams.get('lang');
678
+ adoptLang(lang);
679
+ const { listVisionModels } = await import('../multimodal.mjs');
680
+ const { readGlobalConfig } = await import('../install/install.mjs');
681
+ return sendJson(res, 200, { ok: true, models: await listVisionModels(provider, force, () => readGlobalConfig(), lang) });
682
+ } catch (err) {
683
+ return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
684
+ }
685
+ },
686
+ }));
687
+
688
+ disposers.push(webServer.register({
689
+ kind: 'exact', path: `${ROUTE_BASE}/provider-test`,
690
+ handler: async (req, res) => {
691
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
692
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'POST only' });
693
+ try {
694
+ // The entry comes from the panel form so unsaved edits can be probed.
695
+ const entry = await readBody(req);
696
+ adoptLang(entry?.lang);
697
+ const { testProvider } = await import('../multimodal.mjs');
698
+ const result = await testProvider(entry, entry?.lang);
699
+ return sendJson(res, 200, { ok: true, result });
700
+ } catch (err) {
701
+ return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
702
+ }
703
+ },
704
+ }));
705
+
706
+ disposers.push(webServer.register({
707
+ kind: 'exact', path: `${ROUTE_BASE}/install/status`,
708
+ handler: async (req, res) => {
709
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
710
+ try {
711
+ const { installStatus } = await import(`../install/install.mjs?t=${Date.now()}`);
712
+ return sendJson(res, 200, { ok: true, status: installStatus() });
713
+ } catch (err) {
714
+ return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
715
+ }
716
+ },
717
+ }));
718
+
719
+ disposers.push(webServer.register({
720
+ kind: 'exact', path: `${ROUTE_BASE}/install`,
721
+ handler: async (req, res) => {
722
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
723
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'POST only' }, { allow: 'POST' });
724
+ try {
725
+ const { target, statusline } = await readBody(req);
726
+ // Cache-busted import: the installer must always run the code
727
+ // currently on disk, not whatever this process first loaded —
728
+ // a stale cached copy once re-broke user settings after a fix.
729
+ const { installClaudeCode, installCodex, installHudSegment, uninstallClaudeCode, uninstallCodex } =
730
+ await import(`../install/install.mjs?t=${Date.now()}`);
731
+ if (target === 'claude') {
732
+ const base = await installClaudeCode({ statusline: !!statusline });
733
+ const hud = installHudSegment({});
734
+ return sendJson(res, 200, {
735
+ ok: base.ok,
736
+ actions: [...base.actions, ...hud.actions.map((a) => `hud: ${a}`)],
737
+ });
738
+ }
739
+ if (target === 'codex') return sendJson(res, 200, installCodex({}));
740
+ if (target === 'claude-uninstall') return sendJson(res, 200, uninstallClaudeCode({}));
741
+ if (target === 'codex-uninstall') return sendJson(res, 200, uninstallCodex({}));
742
+ return sendJson(res, 400, { ok: false, error: 'target must be claude | codex | claude-uninstall | codex-uninstall' });
743
+ } catch (err) {
744
+ return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
745
+ }
746
+ },
747
+ }));
748
+
749
+ return () => { for (const d of disposers.reverse()) d(); };
750
+ });
751
+
752
+ ctx.logger?.info?.('dsh-crew hub mounted (jobs API + installer endpoints + multimodal tools)');
753
+ return async () => {
754
+ for (const d of disposers.reverse()) { try { d(); } catch {} }
755
+ await hub.dispose();
756
+ };
757
+ }