@pixelbyte-software/pixcode 1.40.6 → 1.40.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-ZA4sMw0s.js → index-C8XyPjlN.js} +149 -149
- package/dist/index.html +1 -1
- package/dist/landing.html +1 -1
- package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +87 -4
- package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
- package/dist-server/server/services/live-view.js +124 -2
- package/dist-server/server/services/live-view.js.map +1 -1
- package/dist-server/server/services/managed-runtimes.js +58 -9
- package/dist-server/server/services/managed-runtimes.js.map +1 -1
- package/dist-server/server/services/provider-models.js +16 -3
- package/dist-server/server/services/provider-models.js.map +1 -1
- package/dist-server/shared/modelConstants.js +2 -3
- package/dist-server/shared/modelConstants.js.map +1 -1
- package/package.json +1 -1
- package/scripts/smoke/live-view-integration.mjs +111 -2
- package/scripts/smoke/orchestration-model-sync.mjs +30 -0
- package/scripts/smoke/provider-models-opencode-live.mjs +66 -0
- package/server/modules/orchestration/workflows/workflow-runner.ts +108 -4
- package/server/services/live-view.js +128 -2
- package/server/services/managed-runtimes.js +56 -10
- package/server/services/provider-models.js +18 -3
- package/shared/modelConstants.js +2 -3
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const page = readFileSync('src/components/orchestration/OrchestrationPage.tsx', 'utf8');
|
|
7
|
+
const runner = readFileSync('server/modules/orchestration/workflows/workflow-runner.ts', 'utf8');
|
|
8
|
+
|
|
9
|
+
assert.ok(
|
|
10
|
+
page.includes("from '../../hooks/useProviderModels'") || page.includes('from "@/hooks/useProviderModels"'),
|
|
11
|
+
'Orchestration page should use the same live provider model catalog hook as chat.',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
assert.ok(
|
|
15
|
+
page.includes('providerModelCatalogs') && page.includes('sanitizeAgentModel'),
|
|
16
|
+
'Orchestration agents should sanitize stale selected models against the live catalog before starting runs.',
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
assert.ok(
|
|
20
|
+
runner.includes('resolveWorkflowModel') && runner.includes('modelCatalogsByProvider'),
|
|
21
|
+
'Workflow runner should validate provider models server-side before submitting A2A tasks.',
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
assert.ok(
|
|
25
|
+
runner.includes('Original user request')
|
|
26
|
+
&& runner.indexOf('Original user request') < runner.lastIndexOf('workspaceContextPrompt(workspaceTarget)'),
|
|
27
|
+
'Workflow runner should place a labeled original user request before workspace context so agents do not answer the context header.',
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
console.log('orchestration model sync smoke passed');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const tempHome = await mkdtemp(path.join(os.tmpdir(), 'pixcode-provider-models-'));
|
|
9
|
+
process.env.HOME = tempHome;
|
|
10
|
+
process.env.OPENCODE_MODELS_URL = 'https://models.dev/test-api.json';
|
|
11
|
+
|
|
12
|
+
const calls = [];
|
|
13
|
+
globalThis.fetch = async (url) => {
|
|
14
|
+
calls.push(String(url));
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
status: 200,
|
|
18
|
+
async json() {
|
|
19
|
+
return {
|
|
20
|
+
opencode: {
|
|
21
|
+
name: 'OpenCode Zen',
|
|
22
|
+
models: {
|
|
23
|
+
'big-pickle': {
|
|
24
|
+
name: 'Big Pickle',
|
|
25
|
+
cost: { input: 0, output: 0 },
|
|
26
|
+
limit: { context: 128000 },
|
|
27
|
+
},
|
|
28
|
+
'fresh-free': {
|
|
29
|
+
name: 'Fresh Free',
|
|
30
|
+
cost: { input: 0, output: 0 },
|
|
31
|
+
limit: { context: 64000 },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const { getProviderModels } = await import('../../server/services/provider-models.js');
|
|
41
|
+
|
|
42
|
+
const staleStatic = [
|
|
43
|
+
{ value: 'opencode/hy3-preview-free', label: 'Stale Hy3 Preview' },
|
|
44
|
+
{ value: 'opencode/ling-2.6-flash-free', label: 'Stale Ling 2.6 Flash' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const refreshed = await getProviderModels('opencode', {
|
|
48
|
+
forceRefresh: true,
|
|
49
|
+
staticList: staleStatic,
|
|
50
|
+
});
|
|
51
|
+
const refreshedValues = refreshed.models.map((model) => model.value);
|
|
52
|
+
assert.equal(calls.length, 1, 'force refresh should fetch the OpenCode live catalog once');
|
|
53
|
+
assert.ok(refreshedValues.includes('opencode/fresh-free'), 'live OpenCode models should be returned');
|
|
54
|
+
assert.ok(!refreshedValues.includes('opencode/hy3-preview-free'), 'stale static OpenCode models must not be merged into a successful live catalog');
|
|
55
|
+
assert.ok(!refreshedValues.includes('opencode/ling-2.6-flash-free'), 'stale static OpenCode models must not survive a successful live refresh');
|
|
56
|
+
|
|
57
|
+
const cached = await getProviderModels('opencode', {
|
|
58
|
+
staticList: staleStatic,
|
|
59
|
+
});
|
|
60
|
+
const cachedValues = cached.models.map((model) => model.value);
|
|
61
|
+
assert.equal(calls.length, 1, 'fresh cache should be used without another network request');
|
|
62
|
+
assert.ok(cachedValues.includes('opencode/fresh-free'), 'cached live OpenCode models should be returned');
|
|
63
|
+
assert.ok(!cachedValues.includes('opencode/hy3-preview-free'), 'cached live OpenCode catalog must stay free of stale static models');
|
|
64
|
+
assert.ok(!cachedValues.includes('opencode/ling-2.6-flash-free'), 'cached live OpenCode catalog must stay free of stale static models');
|
|
65
|
+
|
|
66
|
+
console.log('provider OpenCode live model catalog smoke passed');
|
|
@@ -13,6 +13,17 @@ import {
|
|
|
13
13
|
workspaceTargetMetadata,
|
|
14
14
|
} from '@/modules/orchestration/workflows/workspace-target.js';
|
|
15
15
|
import { workflowStore } from '@/modules/orchestration/workflows/workflow-store.js';
|
|
16
|
+
// @ts-ignore — plain-JS service
|
|
17
|
+
import { getProviderModels } from '@/services/provider-models.js';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
CLAUDE_MODELS,
|
|
21
|
+
CODEX_MODELS,
|
|
22
|
+
CURSOR_MODELS,
|
|
23
|
+
GEMINI_MODELS,
|
|
24
|
+
OPENCODE_MODELS,
|
|
25
|
+
QWEN_MODELS,
|
|
26
|
+
} from '../../../../shared/modelConstants.js';
|
|
16
27
|
|
|
17
28
|
const TERMINAL = new Set(['completed', 'failed', 'canceled']);
|
|
18
29
|
const SKIPPED = 'skipped';
|
|
@@ -106,6 +117,34 @@ type AgentAssignment = {
|
|
|
106
117
|
|
|
107
118
|
type KnownAgentRole = typeof KNOWN_AGENT_ROLES[number];
|
|
108
119
|
type AgentRole = string;
|
|
120
|
+
type ProviderId = 'claude' | 'cursor' | 'codex' | 'gemini' | 'qwen' | 'opencode';
|
|
121
|
+
type ProviderModel = {
|
|
122
|
+
value: string;
|
|
123
|
+
label?: string;
|
|
124
|
+
source?: 'static' | 'api';
|
|
125
|
+
free?: boolean;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const adapterProviderMap: Record<string, ProviderId | undefined> = {
|
|
129
|
+
'claude-code': 'claude',
|
|
130
|
+
cursor: 'cursor',
|
|
131
|
+
codex: 'codex',
|
|
132
|
+
gemini: 'gemini',
|
|
133
|
+
qwen: 'qwen',
|
|
134
|
+
opencode: 'opencode',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const modelCatalogsByProvider: Record<ProviderId, {
|
|
138
|
+
staticList: ProviderModel[];
|
|
139
|
+
defaultModel?: string;
|
|
140
|
+
}> = {
|
|
141
|
+
claude: { staticList: CLAUDE_MODELS.OPTIONS, defaultModel: CLAUDE_MODELS.DEFAULT },
|
|
142
|
+
cursor: { staticList: CURSOR_MODELS.OPTIONS, defaultModel: CURSOR_MODELS.DEFAULT },
|
|
143
|
+
codex: { staticList: CODEX_MODELS.OPTIONS, defaultModel: CODEX_MODELS.DEFAULT },
|
|
144
|
+
gemini: { staticList: GEMINI_MODELS.OPTIONS, defaultModel: GEMINI_MODELS.DEFAULT },
|
|
145
|
+
qwen: { staticList: QWEN_MODELS.OPTIONS, defaultModel: QWEN_MODELS.DEFAULT },
|
|
146
|
+
opencode: { staticList: OPENCODE_MODELS.OPTIONS, defaultModel: OPENCODE_MODELS.DEFAULT },
|
|
147
|
+
};
|
|
109
148
|
|
|
110
149
|
function readAgentRole(value: unknown): AgentRole | undefined {
|
|
111
150
|
return typeof value === 'string' && value.trim() && value.trim() !== 'auto'
|
|
@@ -133,6 +172,44 @@ function readBoolean(value: unknown): boolean | undefined {
|
|
|
133
172
|
return typeof value === 'boolean' ? value : undefined;
|
|
134
173
|
}
|
|
135
174
|
|
|
175
|
+
function modelValueSet(models: ProviderModel[]): Set<string> {
|
|
176
|
+
return new Set(models.map((model) => model.value).filter(Boolean));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function preferredFallbackModel(models: ProviderModel[], defaultModel?: string): string | undefined {
|
|
180
|
+
const values = modelValueSet(models);
|
|
181
|
+
if (defaultModel && values.has(defaultModel)) return defaultModel;
|
|
182
|
+
return models.find((model) => model.source === 'api' && model.free)?.value
|
|
183
|
+
?? models.find((model) => model.source === 'api')?.value
|
|
184
|
+
?? models.find((model) => model.free)?.value
|
|
185
|
+
?? models[0]?.value
|
|
186
|
+
?? defaultModel;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function resolveWorkflowModel(adapterId: string, requestedModel?: string): Promise<string | undefined> {
|
|
190
|
+
const provider = adapterProviderMap[adapterId];
|
|
191
|
+
if (!provider) return requestedModel;
|
|
192
|
+
|
|
193
|
+
const catalog = modelCatalogsByProvider[provider];
|
|
194
|
+
if (!requestedModel) return catalog.defaultModel;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const result = await getProviderModels(provider, {
|
|
198
|
+
staticList: catalog.staticList,
|
|
199
|
+
});
|
|
200
|
+
const models = Array.isArray(result?.models) ? result.models as ProviderModel[] : [];
|
|
201
|
+
if (modelValueSet(models).has(requestedModel)) {
|
|
202
|
+
return requestedModel;
|
|
203
|
+
}
|
|
204
|
+
return preferredFallbackModel(models, catalog.defaultModel) ?? requestedModel;
|
|
205
|
+
} catch {
|
|
206
|
+
const staticValues = modelValueSet(catalog.staticList);
|
|
207
|
+
return staticValues.has(requestedModel)
|
|
208
|
+
? requestedModel
|
|
209
|
+
: preferredFallbackModel(catalog.staticList, catalog.defaultModel) ?? requestedModel;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
136
213
|
function readIsolation(value: unknown): 'host' | 'worktree' | 'docker' | undefined {
|
|
137
214
|
return value === 'host' || value === 'worktree' || value === 'docker' ? value : undefined;
|
|
138
215
|
}
|
|
@@ -1408,15 +1485,42 @@ class WorkflowRunner {
|
|
|
1408
1485
|
|
|
1409
1486
|
const inputContext = node.inputs.map((input) => outputs.get(input)).filter(Boolean).join('\n\n');
|
|
1410
1487
|
const workspaceTarget = resolveWorkflowWorkspace(run.metadata);
|
|
1411
|
-
const prompt = [
|
|
1412
|
-
|
|
1413
|
-
.
|
|
1488
|
+
const prompt = [
|
|
1489
|
+
'Original user request (primary task; answer this directly even if the workspace is empty):',
|
|
1490
|
+
run.input?.trim() || '(No original user request was provided.)',
|
|
1491
|
+
inputContext
|
|
1492
|
+
? `Upstream workflow context from prior agents:\n${inputContext}`
|
|
1493
|
+
: '',
|
|
1494
|
+
`Current workflow step instructions:\n${node.prompt}`,
|
|
1495
|
+
workspaceContextPrompt(workspaceTarget),
|
|
1496
|
+
].filter(Boolean).join('\n\n');
|
|
1414
1497
|
const settings = getMetadataRecord(run.metadata, 'settings');
|
|
1415
1498
|
const projectPath = workspaceTarget.projectPath;
|
|
1416
1499
|
const isolation = readIsolation(settings.isolation) ?? node.isolation ?? 'host';
|
|
1417
1500
|
const keepAfterCompletion = readBoolean(settings.keepWorkspace) ?? true;
|
|
1418
1501
|
const baseRef = readString(settings.baseRef) ?? 'HEAD';
|
|
1419
1502
|
const effectivePermissionMode = resolveNodePermissionMode(node, workspaceTarget);
|
|
1503
|
+
const effectiveModel = await resolveWorkflowModel(node.adapterId, node.model);
|
|
1504
|
+
if (effectiveModel !== node.model) {
|
|
1505
|
+
nodeRun.model = effectiveModel;
|
|
1506
|
+
const modelFallbackEvents = Array.isArray(run.metadata?.modelFallbackEvents)
|
|
1507
|
+
? run.metadata.modelFallbackEvents
|
|
1508
|
+
: [];
|
|
1509
|
+
run.metadata = {
|
|
1510
|
+
...run.metadata,
|
|
1511
|
+
modelFallbackEvents: [
|
|
1512
|
+
...modelFallbackEvents,
|
|
1513
|
+
{
|
|
1514
|
+
nodeId: node.id,
|
|
1515
|
+
adapterId: node.adapterId,
|
|
1516
|
+
requestedModel: node.model,
|
|
1517
|
+
effectiveModel,
|
|
1518
|
+
changedAt: Date.now(),
|
|
1519
|
+
},
|
|
1520
|
+
],
|
|
1521
|
+
};
|
|
1522
|
+
workflowStore.setRun(run);
|
|
1523
|
+
}
|
|
1420
1524
|
let body: { id?: string; error?: { message?: string } };
|
|
1421
1525
|
try {
|
|
1422
1526
|
const submit = await fetch(`${localA2ABaseUrl()}/tasks`, {
|
|
@@ -1436,7 +1540,7 @@ class WorkflowRunner {
|
|
|
1436
1540
|
agentInstanceId: node.agentInstanceId,
|
|
1437
1541
|
agentLabel: node.agentLabel,
|
|
1438
1542
|
assignment: node.assignment,
|
|
1439
|
-
model:
|
|
1543
|
+
model: effectiveModel,
|
|
1440
1544
|
permissionMode: effectivePermissionMode,
|
|
1441
1545
|
toolsSettings: node.toolsSettings,
|
|
1442
1546
|
projectPath,
|
|
@@ -208,6 +208,120 @@ function buildManagedPackageCommand(command, runtimeStatus) {
|
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
function packageBinCandidates(binName) {
|
|
212
|
+
return process.platform === 'win32'
|
|
213
|
+
? [binName, `${binName}.cmd`, `${binName}.ps1`, `${binName}.exe`]
|
|
214
|
+
: [binName];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function expectedPackageBin(command) {
|
|
218
|
+
if (command.framework === 'Vite' || command.id === 'npm-dev-vite') return 'vite';
|
|
219
|
+
if (command.framework === 'Next.js' || command.id === 'npm-dev-next') return 'next';
|
|
220
|
+
if (command.framework === 'Nuxt' || command.id === 'npm-dev-nuxt') return 'nuxt';
|
|
221
|
+
if (command.framework === 'Astro' || command.id === 'npm-dev-astro') return 'astro';
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function packageDependenciesReady(projectPath, command) {
|
|
226
|
+
if (!command?.scriptName && !command?.packageManager) return true;
|
|
227
|
+
if (!(await dirExists(path.join(projectPath, 'node_modules')))) return false;
|
|
228
|
+
|
|
229
|
+
const binName = expectedPackageBin(command);
|
|
230
|
+
if (!binName) return true;
|
|
231
|
+
|
|
232
|
+
const binDir = path.join(projectPath, 'node_modules', '.bin');
|
|
233
|
+
for (const candidate of packageBinCandidates(binName)) {
|
|
234
|
+
if (await fileExists(path.join(binDir, candidate))) return true;
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function packageInstallInvocation(command) {
|
|
240
|
+
if (!command || command.packageManager !== 'npm') return null;
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
command.managedRuntime?.id === 'npm'
|
|
244
|
+
&& command.args?.[0]
|
|
245
|
+
&& String(command.args[0]).endsWith('npm-cli.js')
|
|
246
|
+
) {
|
|
247
|
+
return {
|
|
248
|
+
command: command.command,
|
|
249
|
+
args: [command.args[0], 'install', '--no-audit', '--no-fund'],
|
|
250
|
+
displayCommand: 'npm install --no-audit --no-fund',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (command.command === 'npm' || path.basename(command.command || '').startsWith('npm')) {
|
|
255
|
+
return {
|
|
256
|
+
command: command.command,
|
|
257
|
+
args: ['install', '--no-audit', '--no-fund'],
|
|
258
|
+
displayCommand: 'npm install --no-audit --no-fund',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function runPrepProcess(command, args, options = {}) {
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
let settled = false;
|
|
268
|
+
const child = spawn(command, args, {
|
|
269
|
+
cwd: options.cwd,
|
|
270
|
+
env: options.env,
|
|
271
|
+
shell: shouldUseShell({ command }),
|
|
272
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
273
|
+
windowsHide: true,
|
|
274
|
+
});
|
|
275
|
+
const timeoutMs = Number.parseInt(process.env.PIXCODE_LIVE_VIEW_INSTALL_TIMEOUT_MS || '', 10) || 300000;
|
|
276
|
+
const finish = (callback, value) => {
|
|
277
|
+
if (settled) return;
|
|
278
|
+
settled = true;
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
callback(value);
|
|
281
|
+
};
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
try {
|
|
284
|
+
child.kill();
|
|
285
|
+
} catch {
|
|
286
|
+
// Process may already be gone.
|
|
287
|
+
}
|
|
288
|
+
finish(reject, new Error(`Dependency install timed out after ${Math.round(timeoutMs / 1000)}s.`));
|
|
289
|
+
}, timeoutMs);
|
|
290
|
+
|
|
291
|
+
child.stdout.on('data', (chunk) => {
|
|
292
|
+
chunk.toString().split(/\r?\n/).forEach((line) => options.onLog?.(line));
|
|
293
|
+
});
|
|
294
|
+
child.stderr.on('data', (chunk) => {
|
|
295
|
+
chunk.toString().split(/\r?\n/).forEach((line) => options.onLog?.(line));
|
|
296
|
+
});
|
|
297
|
+
child.on('error', (error) => finish(reject, error));
|
|
298
|
+
child.on('exit', (code, signal) => {
|
|
299
|
+
if (code === 0) {
|
|
300
|
+
finish(resolve);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
finish(reject, new Error(`Dependency install exited with ${signal || `code ${code}`}.`));
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function preparePackageDependencies(projectPath, command, env = process.env, onLog = () => {}) {
|
|
309
|
+
if (!command || command.packageManager !== 'npm') return false;
|
|
310
|
+
if (await packageDependenciesReady(projectPath, command)) return false;
|
|
311
|
+
|
|
312
|
+
const install = packageInstallInvocation(command);
|
|
313
|
+
if (!install) return false;
|
|
314
|
+
|
|
315
|
+
onLog(`Installing project dependencies: ${install.displayCommand}`);
|
|
316
|
+
await runPrepProcess(install.command, install.args, {
|
|
317
|
+
cwd: projectPath,
|
|
318
|
+
env,
|
|
319
|
+
onLog,
|
|
320
|
+
});
|
|
321
|
+
onLog('Project dependencies are ready.');
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
211
325
|
function buildManagedPhpCommand(runtimeStatus) {
|
|
212
326
|
const executable = runtimeStatus?.executablePath || 'frankenphp';
|
|
213
327
|
return {
|
|
@@ -718,6 +832,20 @@ export async function startLiveView(projectName, projectPath, options = {}) {
|
|
|
718
832
|
BROWSER: 'none',
|
|
719
833
|
NEXT_TELEMETRY_DISABLED: '1',
|
|
720
834
|
};
|
|
835
|
+
|
|
836
|
+
sessionsByProject.set(projectName, session);
|
|
837
|
+
sessionsByShareId.set(shareId, session);
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
await preparePackageDependencies(projectPath, command, env, (line) => appendLog(session, line));
|
|
841
|
+
} catch (error) {
|
|
842
|
+
session.status = 'error';
|
|
843
|
+
session.stoppedAt = new Date().toISOString();
|
|
844
|
+
session.error = error instanceof Error ? error.message : String(error);
|
|
845
|
+
appendLog(session, session.error);
|
|
846
|
+
return publicSession(session);
|
|
847
|
+
}
|
|
848
|
+
|
|
721
849
|
const child = spawn(command.command, command.args, {
|
|
722
850
|
cwd: projectPath,
|
|
723
851
|
env,
|
|
@@ -726,8 +854,6 @@ export async function startLiveView(projectName, projectPath, options = {}) {
|
|
|
726
854
|
});
|
|
727
855
|
|
|
728
856
|
session.child = child;
|
|
729
|
-
sessionsByProject.set(projectName, session);
|
|
730
|
-
sessionsByShareId.set(shareId, session);
|
|
731
857
|
|
|
732
858
|
child.stdout.on('data', (chunk) => {
|
|
733
859
|
chunk.toString().split(/\r?\n/).forEach((line) => appendLog(session, line));
|
|
@@ -121,21 +121,39 @@ async function downloadFile(url, targetFile, env = process.env) {
|
|
|
121
121
|
function runProcess(command, args, options = {}) {
|
|
122
122
|
return new Promise((resolve, reject) => {
|
|
123
123
|
let stderr = '';
|
|
124
|
+
let settled = false;
|
|
125
|
+
const { timeoutMs, ...spawnOptions } = options;
|
|
124
126
|
const child = spawn(command, args, {
|
|
125
|
-
...
|
|
127
|
+
...spawnOptions,
|
|
126
128
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
127
129
|
windowsHide: true,
|
|
128
130
|
});
|
|
131
|
+
const finish = (callback, value) => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
settled = true;
|
|
134
|
+
if (timer) clearTimeout(timer);
|
|
135
|
+
callback(value);
|
|
136
|
+
};
|
|
137
|
+
const timer = timeoutMs
|
|
138
|
+
? setTimeout(() => {
|
|
139
|
+
try {
|
|
140
|
+
child.kill();
|
|
141
|
+
} catch {
|
|
142
|
+
// Process may have exited between timeout scheduling and kill.
|
|
143
|
+
}
|
|
144
|
+
finish(reject, new Error(`${command} timed out after ${timeoutMs}ms`));
|
|
145
|
+
}, timeoutMs)
|
|
146
|
+
: null;
|
|
129
147
|
child.stderr.on('data', (chunk) => {
|
|
130
148
|
stderr += chunk.toString();
|
|
131
149
|
});
|
|
132
|
-
child.on('error', reject);
|
|
150
|
+
child.on('error', (error) => finish(reject, error));
|
|
133
151
|
child.on('close', (code) => {
|
|
134
152
|
if (code === 0) {
|
|
135
|
-
resolve
|
|
153
|
+
finish(resolve);
|
|
136
154
|
return;
|
|
137
155
|
}
|
|
138
|
-
reject
|
|
156
|
+
finish(reject, new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
139
157
|
});
|
|
140
158
|
});
|
|
141
159
|
}
|
|
@@ -200,6 +218,27 @@ async function findRuntimeExecutable(searchRoot, binaryName) {
|
|
|
200
218
|
return null;
|
|
201
219
|
}
|
|
202
220
|
|
|
221
|
+
async function copyRuntimeWithSidecars(executablePath, currentDir) {
|
|
222
|
+
const executableDir = path.dirname(executablePath);
|
|
223
|
+
await fs.rm(currentDir, { recursive: true, force: true });
|
|
224
|
+
await fs.mkdir(currentDir, { recursive: true });
|
|
225
|
+
await fs.cp(executableDir, currentDir, { recursive: true, force: true });
|
|
226
|
+
return path.join(currentDir, path.basename(executablePath));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function validateManagedRuntimeExecutable(id, executablePath, env = process.env) {
|
|
230
|
+
if (id !== 'frankenphp') return true;
|
|
231
|
+
try {
|
|
232
|
+
await runProcess(executablePath, ['version'], {
|
|
233
|
+
env,
|
|
234
|
+
timeoutMs: 5000,
|
|
235
|
+
});
|
|
236
|
+
return true;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
203
242
|
async function installFrankenPhp(env = process.env) {
|
|
204
243
|
const releaseApiUrl = env.PIXCODE_FRANKENPHP_RELEASE_API
|
|
205
244
|
|| 'https://api.github.com/repos/php/frankenphp/releases/latest';
|
|
@@ -245,12 +284,7 @@ async function installFrankenPhp(env = process.env) {
|
|
|
245
284
|
await fs.chmod(executablePath, 0o755).catch(() => undefined);
|
|
246
285
|
}
|
|
247
286
|
|
|
248
|
-
|
|
249
|
-
await fs.mkdir(currentDir, { recursive: true });
|
|
250
|
-
|
|
251
|
-
const finalName = process.platform === 'win32' ? 'frankenphp.exe' : 'frankenphp';
|
|
252
|
-
const finalExecutable = path.join(currentDir, finalName);
|
|
253
|
-
await fs.copyFile(executablePath, finalExecutable);
|
|
287
|
+
const finalExecutable = await copyRuntimeWithSidecars(executablePath, currentDir);
|
|
254
288
|
if (process.platform !== 'win32') {
|
|
255
289
|
await fs.chmod(finalExecutable, 0o755).catch(() => undefined);
|
|
256
290
|
}
|
|
@@ -379,6 +413,18 @@ export async function getManagedRuntimeStatus(id, options = {}) {
|
|
|
379
413
|
|
|
380
414
|
const manifest = await readManifest(id, env);
|
|
381
415
|
if (manifest) {
|
|
416
|
+
const valid = await validateManagedRuntimeExecutable(id, manifest.executablePath, env);
|
|
417
|
+
if (!valid) {
|
|
418
|
+
return {
|
|
419
|
+
id,
|
|
420
|
+
label: 'Pixcode PHP runtime',
|
|
421
|
+
provider: 'FrankenPHP',
|
|
422
|
+
status: 'missing',
|
|
423
|
+
installable: true,
|
|
424
|
+
reason: 'The existing Pixcode PHP runtime is incomplete or cannot start. Pixcode will reinstall it automatically.',
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
382
428
|
return {
|
|
383
429
|
id,
|
|
384
430
|
label: manifest.label || 'Pixcode PHP runtime',
|
|
@@ -86,6 +86,20 @@ function mergeCatalogs(primary, secondary) {
|
|
|
86
86
|
return Array.from(seen.values());
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function mergeProviderCatalogs(provider, primary, staticCatalog) {
|
|
90
|
+
const normalizedPrimary = normalizeList(primary);
|
|
91
|
+
|
|
92
|
+
// OpenCode Zen free models rotate often. When models.dev succeeds, treat
|
|
93
|
+
// that live catalog as authoritative; otherwise stale static freebies can
|
|
94
|
+
// leak back into the UI and fail later with ProviderModelNotFoundError.
|
|
95
|
+
if (provider === 'opencode') {
|
|
96
|
+
const liveModels = normalizedPrimary.filter((item) => item.source === 'api');
|
|
97
|
+
if (liveModels.length > 0) return liveModels;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return mergeCatalogs(normalizedPrimary, staticCatalog);
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
// ---------------- Per-provider live discovery ----------------
|
|
90
104
|
|
|
91
105
|
async function discoverAnthropic(apiKey, baseUrl) {
|
|
@@ -287,8 +301,9 @@ export async function getProviderModels(provider, opts = {}) {
|
|
|
287
301
|
: false;
|
|
288
302
|
|
|
289
303
|
if (!forceRefresh && cacheFresh && Array.isArray(cached?.models)) {
|
|
304
|
+
const merged = mergeProviderCatalogs(provider, cached.models, staticCatalog);
|
|
290
305
|
return {
|
|
291
|
-
models:
|
|
306
|
+
models: merged,
|
|
292
307
|
fetchedAt: cached.fetchedAt,
|
|
293
308
|
error: cached.error,
|
|
294
309
|
fromCache: true,
|
|
@@ -305,7 +320,7 @@ export async function getProviderModels(provider, opts = {}) {
|
|
|
305
320
|
} catch (err) {
|
|
306
321
|
error = err?.message || String(err);
|
|
307
322
|
}
|
|
308
|
-
const merged =
|
|
323
|
+
const merged = mergeProviderCatalogs(provider, liveModels, staticCatalog);
|
|
309
324
|
const entry = { models: merged, error };
|
|
310
325
|
await saveCacheEntry(provider, entry).catch(() => { /* non-fatal */ });
|
|
311
326
|
return { models: merged, fetchedAt: new Date().toISOString(), error, fromCache: false };
|
|
@@ -367,7 +382,7 @@ export async function getProviderModels(provider, opts = {}) {
|
|
|
367
382
|
}
|
|
368
383
|
}
|
|
369
384
|
|
|
370
|
-
const merged =
|
|
385
|
+
const merged = mergeProviderCatalogs(provider, liveModels, staticCatalog);
|
|
371
386
|
const entry = { models: merged, error };
|
|
372
387
|
await saveCacheEntry(provider, entry).catch(() => { /* non-fatal */ });
|
|
373
388
|
return { models: merged, fetchedAt: new Date().toISOString(), error, fromCache: false };
|
package/shared/modelConstants.js
CHANGED
|
@@ -110,11 +110,10 @@ export const QWEN_MODELS = {
|
|
|
110
110
|
export const OPENCODE_MODELS = {
|
|
111
111
|
OPTIONS: [
|
|
112
112
|
// OpenCode Zen — free tier (no charge, may rate-limit). The "limited
|
|
113
|
-
// time" Zen freebies rotate, so this
|
|
113
|
+
// time" Zen freebies rotate, so keep this fallback conservative and let
|
|
114
|
+
// the live models.dev catalog populate the full current list.
|
|
114
115
|
{ value: "opencode/big-pickle", label: "OpenCode Zen · Big Pickle (Free)", free: true },
|
|
115
116
|
{ value: "opencode/minimax-m2.5-free", label: "OpenCode Zen · MiniMax M2.5 (Free)", free: true },
|
|
116
|
-
{ value: "opencode/hy3-preview-free", label: "OpenCode Zen · Hy3 Preview (Free)", free: true },
|
|
117
|
-
{ value: "opencode/ling-2.6-flash-free", label: "OpenCode Zen · Ling 2.6 Flash (Free)", free: true },
|
|
118
117
|
{ value: "opencode/nemotron-3-super-free", label: "OpenCode Zen · Nemotron 3 Super (Free)", free: true },
|
|
119
118
|
{ value: "opencode/gpt-5-nano", label: "OpenCode Zen · GPT-5 Nano (Free)", free: true },
|
|
120
119
|
|