@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.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.de.md +359 -0
- package/README.es.md +359 -0
- package/README.fr.md +359 -0
- package/README.hi.md +359 -0
- package/README.id.md +359 -0
- package/README.ja.md +359 -0
- package/README.ko.md +359 -0
- package/README.md +360 -0
- package/README.pt.md +359 -0
- package/README.ru.md +359 -0
- package/README.th.md +359 -0
- package/README.tr.md +359 -0
- package/README.vi.md +359 -0
- package/README.zh-TW.md +359 -0
- package/README.zh.md +305 -0
- package/agents/ds-flash.md +26 -0
- package/agents/ds-pro.md +32 -0
- package/agents/ds-reviewer.md +23 -0
- package/agents/ds-worker.md +22 -0
- package/codex/agents/ds-flash.toml +30 -0
- package/codex/agents/ds-pro.toml +31 -0
- package/codex/agents/ds-reviewer.toml +28 -0
- package/codex/agents/ds-worker.toml +28 -0
- package/codex/prompts/dsh-config.md +3 -0
- package/codex/prompts/dsh-status.md +1 -0
- package/commands/config.md +11 -0
- package/commands/off.md +5 -0
- package/commands/on.md +5 -0
- package/commands/status.md +5 -0
- package/cordis.patch.yml +4 -0
- package/docs/images/dsh-crew-host.png +0 -0
- package/docs/images/dsh-crew-jobs.png +0 -0
- package/docs/images/dsh-crew-logo.png +0 -0
- package/docs/images/dsh-crew-overview.png +0 -0
- package/lib/client.js +2765 -0
- package/package.json +125 -0
- package/scripts/build-client.mjs +28 -0
- package/scripts/live-crew-smoke.mjs +39 -0
- package/scripts/live-policy-matrix.mjs +177 -0
- package/scripts/policy-probe.mjs +101 -0
- package/scripts/setup.mjs +294 -0
- package/scripts/smoke-real.mjs +110 -0
- package/scripts/smoke.mjs +78 -0
- package/scripts/verify-installer-fix.mjs +26 -0
- package/src/adaptive-routing.mjs +260 -0
- package/src/client/activation-summary.tsx +64 -0
- package/src/client/entry.tsx +236 -0
- package/src/client/index.tsx +1120 -0
- package/src/config-readiness.mjs +59 -0
- package/src/delivery.mjs +205 -0
- package/src/dsh-cli-runtime.mjs +251 -0
- package/src/failure-classification.mjs +172 -0
- package/src/hub/entry.mjs +98 -0
- package/src/hub/index.mjs +757 -0
- package/src/hub-client.mjs +132 -0
- package/src/hub-compatibility.mjs +49 -0
- package/src/i18n.mjs +19 -0
- package/src/install/cli.mjs +28 -0
- package/src/install/install-legacy.mjs +460 -0
- package/src/install/install.mjs +451 -0
- package/src/jobs.mjs +275 -0
- package/src/mcp-runtime.mjs +257 -0
- package/src/model-catalog.mjs +173 -0
- package/src/model-routing.mjs +391 -0
- package/src/multimodal.mjs +0 -0
- package/src/policy-legacy.mjs +830 -0
- package/src/policy.mjs +197 -0
- package/src/readiness-matrix.mjs +169 -0
- package/src/runtime-controls.mjs +90 -0
- package/src/runtime-identity.mjs +108 -0
- package/src/server.mjs +477 -0
- package/src/status-shard.mjs +52 -0
- package/src/structured-error-code.mjs +39 -0
- package/src/vision-route.mjs +138 -0
- package/src/workflow-runtime.mjs +567 -0
- package/src/workflow.mjs +160 -0
- package/src/workspace-audit.mjs +231 -0
- package/src/workspace-isolation.mjs +306 -0
- package/statusline/statusline.sh +14 -0
- package/statusline/worker-segment.sh +35 -0
- 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
|
+
}
|