@pixelbyte-software/pixcode 1.40.6 → 1.40.9

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 (29) hide show
  1. package/dist/assets/{index-ZA4sMw0s.js → index-BIWB3xUE.js} +149 -149
  2. package/dist/index.html +1 -1
  3. package/dist/landing.html +1 -1
  4. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +133 -4
  5. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
  6. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js +9 -1
  7. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js.map +1 -1
  8. package/dist-server/server/services/live-view.js +159 -3
  9. package/dist-server/server/services/live-view.js.map +1 -1
  10. package/dist-server/server/services/managed-runtimes.js +58 -9
  11. package/dist-server/server/services/managed-runtimes.js.map +1 -1
  12. package/dist-server/server/services/notification-orchestrator.js +2 -1
  13. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  14. package/dist-server/server/services/provider-models.js +16 -3
  15. package/dist-server/server/services/provider-models.js.map +1 -1
  16. package/dist-server/shared/modelConstants.js +2 -3
  17. package/dist-server/shared/modelConstants.js.map +1 -1
  18. package/package.json +1 -1
  19. package/scripts/smoke/live-view-integration.mjs +156 -2
  20. package/scripts/smoke/notification-center.mjs +24 -0
  21. package/scripts/smoke/orchestration-model-sync.mjs +30 -0
  22. package/scripts/smoke/provider-models-opencode-live.mjs +66 -0
  23. package/server/modules/orchestration/workflows/workflow-runner.ts +174 -4
  24. package/server/modules/orchestration/workflows/workflow.routes.ts +10 -1
  25. package/server/services/live-view.js +166 -3
  26. package/server/services/managed-runtimes.js +56 -10
  27. package/server/services/notification-orchestrator.js +2 -1
  28. package/server/services/provider-models.js +18 -3
  29. package/shared/modelConstants.js +2 -3
@@ -13,6 +13,19 @@ 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
+ // @ts-ignore — plain-JS service
19
+ import { notifyRunFailed, notifyRunStopped } from '@/services/notification-orchestrator.js';
20
+
21
+ import {
22
+ CLAUDE_MODELS,
23
+ CODEX_MODELS,
24
+ CURSOR_MODELS,
25
+ GEMINI_MODELS,
26
+ OPENCODE_MODELS,
27
+ QWEN_MODELS,
28
+ } from '../../../../shared/modelConstants.js';
16
29
 
17
30
  const TERMINAL = new Set(['completed', 'failed', 'canceled']);
18
31
  const SKIPPED = 'skipped';
@@ -106,6 +119,51 @@ type AgentAssignment = {
106
119
 
107
120
  type KnownAgentRole = typeof KNOWN_AGENT_ROLES[number];
108
121
  type AgentRole = string;
122
+ type ProviderId = 'claude' | 'cursor' | 'codex' | 'gemini' | 'qwen' | 'opencode';
123
+ type ProviderModel = {
124
+ value: string;
125
+ label?: string;
126
+ source?: 'static' | 'api';
127
+ free?: boolean;
128
+ };
129
+ type RunStoppedNotifier = (payload: {
130
+ userId: string | number;
131
+ provider: string;
132
+ sessionId?: string | null;
133
+ stopReason?: string;
134
+ sessionName?: string | null;
135
+ }) => void;
136
+ type RunFailedNotifier = (payload: {
137
+ userId: string | number;
138
+ provider: string;
139
+ sessionId?: string | null;
140
+ error: unknown;
141
+ sessionName?: string | null;
142
+ }) => void;
143
+
144
+ const sendRunStoppedNotification = notifyRunStopped as RunStoppedNotifier;
145
+ const sendRunFailedNotification = notifyRunFailed as RunFailedNotifier;
146
+
147
+ const adapterProviderMap: Record<string, ProviderId | undefined> = {
148
+ 'claude-code': 'claude',
149
+ cursor: 'cursor',
150
+ codex: 'codex',
151
+ gemini: 'gemini',
152
+ qwen: 'qwen',
153
+ opencode: 'opencode',
154
+ };
155
+
156
+ const modelCatalogsByProvider: Record<ProviderId, {
157
+ staticList: ProviderModel[];
158
+ defaultModel?: string;
159
+ }> = {
160
+ claude: { staticList: CLAUDE_MODELS.OPTIONS, defaultModel: CLAUDE_MODELS.DEFAULT },
161
+ cursor: { staticList: CURSOR_MODELS.OPTIONS, defaultModel: CURSOR_MODELS.DEFAULT },
162
+ codex: { staticList: CODEX_MODELS.OPTIONS, defaultModel: CODEX_MODELS.DEFAULT },
163
+ gemini: { staticList: GEMINI_MODELS.OPTIONS, defaultModel: GEMINI_MODELS.DEFAULT },
164
+ qwen: { staticList: QWEN_MODELS.OPTIONS, defaultModel: QWEN_MODELS.DEFAULT },
165
+ opencode: { staticList: OPENCODE_MODELS.OPTIONS, defaultModel: OPENCODE_MODELS.DEFAULT },
166
+ };
109
167
 
110
168
  function readAgentRole(value: unknown): AgentRole | undefined {
111
169
  return typeof value === 'string' && value.trim() && value.trim() !== 'auto'
@@ -129,10 +187,94 @@ function readString(value: unknown): string | undefined {
129
187
  return typeof value === 'string' && value.trim() ? value : undefined;
130
188
  }
131
189
 
190
+ function readNotificationUserId(metadata?: Record<string, unknown>): string | number | null {
191
+ const value = metadata?.userId;
192
+ return typeof value === 'string' || typeof value === 'number' ? value : null;
193
+ }
194
+
195
+ function workflowNotificationTitle(run: WorkflowRun): string {
196
+ return readString(run.metadata?.workflowName) ?? run.workflowId;
197
+ }
198
+
199
+ function notifyWorkflowRunFinished(run: WorkflowRun): void {
200
+ const userId = readNotificationUserId(run.metadata);
201
+ if (!userId) return;
202
+
203
+ if (run.status === 'completed') {
204
+ sendRunStoppedNotification({
205
+ userId,
206
+ provider: 'system',
207
+ sessionId: run.id,
208
+ sessionName: workflowNotificationTitle(run),
209
+ stopReason: 'Orchestration completed',
210
+ });
211
+ return;
212
+ }
213
+
214
+ if (run.status === 'canceled') {
215
+ sendRunStoppedNotification({
216
+ userId,
217
+ provider: 'system',
218
+ sessionId: run.id,
219
+ sessionName: workflowNotificationTitle(run),
220
+ stopReason: 'Orchestration canceled',
221
+ });
222
+ return;
223
+ }
224
+
225
+ if (run.status === 'failed') {
226
+ sendRunFailedNotification({
227
+ userId,
228
+ provider: 'system',
229
+ sessionId: run.id,
230
+ sessionName: workflowNotificationTitle(run),
231
+ error: readString(run.metadata?.error) ?? 'Orchestration failed',
232
+ });
233
+ }
234
+ }
235
+
132
236
  function readBoolean(value: unknown): boolean | undefined {
133
237
  return typeof value === 'boolean' ? value : undefined;
134
238
  }
135
239
 
240
+ function modelValueSet(models: ProviderModel[]): Set<string> {
241
+ return new Set(models.map((model) => model.value).filter(Boolean));
242
+ }
243
+
244
+ function preferredFallbackModel(models: ProviderModel[], defaultModel?: string): string | undefined {
245
+ const values = modelValueSet(models);
246
+ if (defaultModel && values.has(defaultModel)) return defaultModel;
247
+ return models.find((model) => model.source === 'api' && model.free)?.value
248
+ ?? models.find((model) => model.source === 'api')?.value
249
+ ?? models.find((model) => model.free)?.value
250
+ ?? models[0]?.value
251
+ ?? defaultModel;
252
+ }
253
+
254
+ async function resolveWorkflowModel(adapterId: string, requestedModel?: string): Promise<string | undefined> {
255
+ const provider = adapterProviderMap[adapterId];
256
+ if (!provider) return requestedModel;
257
+
258
+ const catalog = modelCatalogsByProvider[provider];
259
+ if (!requestedModel) return catalog.defaultModel;
260
+
261
+ try {
262
+ const result = await getProviderModels(provider, {
263
+ staticList: catalog.staticList,
264
+ });
265
+ const models = Array.isArray(result?.models) ? result.models as ProviderModel[] : [];
266
+ if (modelValueSet(models).has(requestedModel)) {
267
+ return requestedModel;
268
+ }
269
+ return preferredFallbackModel(models, catalog.defaultModel) ?? requestedModel;
270
+ } catch {
271
+ const staticValues = modelValueSet(catalog.staticList);
272
+ return staticValues.has(requestedModel)
273
+ ? requestedModel
274
+ : preferredFallbackModel(catalog.staticList, catalog.defaultModel) ?? requestedModel;
275
+ }
276
+ }
277
+
136
278
  function readIsolation(value: unknown): 'host' | 'worktree' | 'docker' | undefined {
137
279
  return value === 'host' || value === 'worktree' || value === 'docker' ? value : undefined;
138
280
  }
@@ -1372,6 +1514,7 @@ class WorkflowRunner {
1372
1514
  } finally {
1373
1515
  run.finishedAt = run.finishedAt ?? Date.now();
1374
1516
  workflowStore.setRun(run);
1517
+ notifyWorkflowRunFinished(run);
1375
1518
  this.cancelingRuns.delete(run.id);
1376
1519
  }
1377
1520
  }
@@ -1408,15 +1551,42 @@ class WorkflowRunner {
1408
1551
 
1409
1552
  const inputContext = node.inputs.map((input) => outputs.get(input)).filter(Boolean).join('\n\n');
1410
1553
  const workspaceTarget = resolveWorkflowWorkspace(run.metadata);
1411
- const prompt = [workspaceContextPrompt(workspaceTarget), run.input, inputContext, node.prompt]
1412
- .filter(Boolean)
1413
- .join('\n\n');
1554
+ const prompt = [
1555
+ 'Original user request (primary task; answer this directly even if the workspace is empty):',
1556
+ run.input?.trim() || '(No original user request was provided.)',
1557
+ inputContext
1558
+ ? `Upstream workflow context from prior agents:\n${inputContext}`
1559
+ : '',
1560
+ `Current workflow step instructions:\n${node.prompt}`,
1561
+ workspaceContextPrompt(workspaceTarget),
1562
+ ].filter(Boolean).join('\n\n');
1414
1563
  const settings = getMetadataRecord(run.metadata, 'settings');
1415
1564
  const projectPath = workspaceTarget.projectPath;
1416
1565
  const isolation = readIsolation(settings.isolation) ?? node.isolation ?? 'host';
1417
1566
  const keepAfterCompletion = readBoolean(settings.keepWorkspace) ?? true;
1418
1567
  const baseRef = readString(settings.baseRef) ?? 'HEAD';
1419
1568
  const effectivePermissionMode = resolveNodePermissionMode(node, workspaceTarget);
1569
+ const effectiveModel = await resolveWorkflowModel(node.adapterId, node.model);
1570
+ if (effectiveModel !== node.model) {
1571
+ nodeRun.model = effectiveModel;
1572
+ const modelFallbackEvents = Array.isArray(run.metadata?.modelFallbackEvents)
1573
+ ? run.metadata.modelFallbackEvents
1574
+ : [];
1575
+ run.metadata = {
1576
+ ...run.metadata,
1577
+ modelFallbackEvents: [
1578
+ ...modelFallbackEvents,
1579
+ {
1580
+ nodeId: node.id,
1581
+ adapterId: node.adapterId,
1582
+ requestedModel: node.model,
1583
+ effectiveModel,
1584
+ changedAt: Date.now(),
1585
+ },
1586
+ ],
1587
+ };
1588
+ workflowStore.setRun(run);
1589
+ }
1420
1590
  let body: { id?: string; error?: { message?: string } };
1421
1591
  try {
1422
1592
  const submit = await fetch(`${localA2ABaseUrl()}/tasks`, {
@@ -1436,7 +1606,7 @@ class WorkflowRunner {
1436
1606
  agentInstanceId: node.agentInstanceId,
1437
1607
  agentLabel: node.agentLabel,
1438
1608
  assignment: node.assignment,
1439
- model: node.model,
1609
+ model: effectiveModel,
1440
1610
  permissionMode: effectivePermissionMode,
1441
1611
  toolsSettings: node.toolsSettings,
1442
1612
  projectPath,
@@ -39,6 +39,11 @@ function readMetadata(body: unknown): Record<string, unknown> | undefined {
39
39
  return metadata && typeof metadata === 'object' ? metadata as Record<string, unknown> : undefined;
40
40
  }
41
41
 
42
+ function readRequestUserId(req: express.Request): string | number | null {
43
+ const user = (req as express.Request & { user?: { id?: string | number; userId?: string | number } }).user;
44
+ return user?.id ?? user?.userId ?? null;
45
+ }
46
+
42
47
  function sendRunSnapshot(res: express.Response, runId: string): boolean {
43
48
  const run = workflowStore.getRun(runId);
44
49
  if (!run) {
@@ -179,7 +184,11 @@ export function createWorkflowRouter(): Router {
179
184
  const run = workflowRunner.start(
180
185
  workflow,
181
186
  typeof req.body?.input === 'string' ? req.body.input : '',
182
- readMetadata(req.body),
187
+ {
188
+ ...readMetadata(req.body),
189
+ userId: readRequestUserId(req),
190
+ workflowName: workflow.name,
191
+ },
183
192
  );
184
193
  res.status(202).json(run);
185
194
  } catch (error) {
@@ -208,6 +208,155 @@ 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
+ const installArgs = ['install', '--no-audit', '--no-fund', '--include=dev'];
242
+
243
+ if (
244
+ command.managedRuntime?.id === 'npm'
245
+ && command.args?.[0]
246
+ && String(command.args[0]).endsWith('npm-cli.js')
247
+ ) {
248
+ return {
249
+ command: command.command,
250
+ args: [command.args[0], ...installArgs],
251
+ displayCommand: 'npm install --no-audit --no-fund --include=dev',
252
+ };
253
+ }
254
+
255
+ if (command.command === 'npm' || path.basename(command.command || '').startsWith('npm')) {
256
+ return {
257
+ command: command.command,
258
+ args: installArgs,
259
+ displayCommand: 'npm install --no-audit --no-fund --include=dev',
260
+ };
261
+ }
262
+
263
+ return null;
264
+ }
265
+
266
+ function runPrepProcess(command, args, options = {}) {
267
+ return new Promise((resolve, reject) => {
268
+ let settled = false;
269
+ const child = spawn(command, args, {
270
+ cwd: options.cwd,
271
+ env: options.env,
272
+ shell: shouldUseShell({ command }),
273
+ stdio: ['ignore', 'pipe', 'pipe'],
274
+ windowsHide: true,
275
+ });
276
+ const timeoutMs = Number.parseInt(process.env.PIXCODE_LIVE_VIEW_INSTALL_TIMEOUT_MS || '', 10) || 300000;
277
+ const finish = (callback, value) => {
278
+ if (settled) return;
279
+ settled = true;
280
+ clearTimeout(timer);
281
+ callback(value);
282
+ };
283
+ const timer = setTimeout(() => {
284
+ try {
285
+ child.kill();
286
+ } catch {
287
+ // Process may already be gone.
288
+ }
289
+ finish(reject, new Error(`Dependency install timed out after ${Math.round(timeoutMs / 1000)}s.`));
290
+ }, timeoutMs);
291
+
292
+ child.stdout.on('data', (chunk) => {
293
+ chunk.toString().split(/\r?\n/).forEach((line) => options.onLog?.(line));
294
+ });
295
+ child.stderr.on('data', (chunk) => {
296
+ chunk.toString().split(/\r?\n/).forEach((line) => options.onLog?.(line));
297
+ });
298
+ child.on('error', (error) => finish(reject, error));
299
+ child.on('exit', (code, signal) => {
300
+ if (code === 0) {
301
+ finish(resolve);
302
+ return;
303
+ }
304
+ finish(reject, new Error(`Dependency install exited with ${signal || `code ${code}`}.`));
305
+ });
306
+ });
307
+ }
308
+
309
+ export async function preparePackageDependencies(projectPath, command, env = process.env, onLog = () => {}) {
310
+ if (!command || command.packageManager !== 'npm') return false;
311
+ if (await packageDependenciesReady(projectPath, command)) return false;
312
+
313
+ const install = packageInstallInvocation(command);
314
+ if (!install) return false;
315
+
316
+ onLog(`Installing project dependencies: ${install.displayCommand}`);
317
+ await runPrepProcess(install.command, install.args, {
318
+ cwd: projectPath,
319
+ env: {
320
+ ...env,
321
+ NODE_ENV: 'development',
322
+ NPM_CONFIG_PRODUCTION: 'false',
323
+ npm_config_production: 'false',
324
+ },
325
+ onLog,
326
+ });
327
+ if (!(await packageDependenciesReady(projectPath, command))) {
328
+ const binName = expectedPackageBin(command);
329
+ throw new Error(binName
330
+ ? `${binName} was not installed. Check package.json dependencies or install output.`
331
+ : 'Project dependencies were not installed.');
332
+ }
333
+ onLog('Project dependencies are ready.');
334
+ return true;
335
+ }
336
+
337
+ function prependPathEntries(env, entries) {
338
+ const cleanEntries = entries.filter(Boolean);
339
+ if (!cleanEntries.length) return env;
340
+
341
+ const existingPath = env.Path || env.PATH || '';
342
+ const nextPath = [...cleanEntries, existingPath].filter(Boolean).join(path.delimiter);
343
+ return process.platform === 'win32'
344
+ ? { ...env, PATH: nextPath, Path: nextPath }
345
+ : { ...env, PATH: nextPath };
346
+ }
347
+
348
+ function applyManagedRuntimeEnv(env, runtimeStatus) {
349
+ if (runtimeStatus?.id !== 'frankenphp' || !runtimeStatus.executablePath) {
350
+ return env;
351
+ }
352
+
353
+ const runtimeDir = path.dirname(runtimeStatus.executablePath);
354
+ return prependPathEntries(env, [
355
+ runtimeDir,
356
+ path.join(runtimeDir, 'ext'),
357
+ ]);
358
+ }
359
+
211
360
  function buildManagedPhpCommand(runtimeStatus) {
212
361
  const executable = runtimeStatus?.executablePath || 'frankenphp';
213
362
  return {
@@ -711,6 +860,7 @@ export async function startLiveView(projectName, projectPath, options = {}) {
711
860
  ...buildCliSpawnEnv(process.env),
712
861
  ...(command.env || {}),
713
862
  ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
863
+ NODE_ENV: 'development',
714
864
  PORT: String(port),
715
865
  HOST: '127.0.0.1',
716
866
  VITE_HOST: '127.0.0.1',
@@ -718,16 +868,29 @@ export async function startLiveView(projectName, projectPath, options = {}) {
718
868
  BROWSER: 'none',
719
869
  NEXT_TELEMETRY_DISABLED: '1',
720
870
  };
871
+ const spawnEnv = applyManagedRuntimeEnv(env, runtimeStatus);
872
+
873
+ sessionsByProject.set(projectName, session);
874
+ sessionsByShareId.set(shareId, session);
875
+
876
+ try {
877
+ await preparePackageDependencies(projectPath, command, spawnEnv, (line) => appendLog(session, line));
878
+ } catch (error) {
879
+ session.status = 'error';
880
+ session.stoppedAt = new Date().toISOString();
881
+ session.error = error instanceof Error ? error.message : String(error);
882
+ appendLog(session, session.error);
883
+ return publicSession(session);
884
+ }
885
+
721
886
  const child = spawn(command.command, command.args, {
722
887
  cwd: projectPath,
723
- env,
888
+ env: spawnEnv,
724
889
  shell: shouldUseShell(command),
725
890
  stdio: ['ignore', 'pipe', 'pipe'],
726
891
  });
727
892
 
728
893
  session.child = child;
729
- sessionsByProject.set(projectName, session);
730
- sessionsByShareId.set(shareId, session);
731
894
 
732
895
  child.stdout.on('data', (chunk) => {
733
896
  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
- ...options,
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(new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
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
- await fs.rm(currentDir, { recursive: true, force: true });
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',
@@ -172,8 +172,9 @@ function buildPushBody(event) {
172
172
 
173
173
  function buildNotificationPayload(event) {
174
174
  const pushBody = buildPushBody(event);
175
+ const baseId = event.dedupeKey || `${event.provider || 'system'}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
175
176
  return {
176
- id: event.dedupeKey || `${event.provider || 'system'}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}:${event.createdAt}`,
177
+ id: `${baseId}:${event.createdAt}`,
177
178
  title: pushBody.title,
178
179
  body: pushBody.body,
179
180
  kind: event.kind || 'info',
@@ -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: mergeCatalogs(normalizeList(cached.models), staticCatalog),
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 = mergeCatalogs(normalizeList(liveModels), staticCatalog);
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 = mergeCatalogs(normalizeList(liveModels), staticCatalog);
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 };
@@ -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 is the safest small set.
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