@pixelbyte-software/pixcode 1.35.4 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.de.md +116 -198
  2. package/README.ja.md +116 -192
  3. package/README.ko.md +116 -192
  4. package/README.md +201 -223
  5. package/README.ru.md +116 -198
  6. package/README.tr.md +205 -175
  7. package/README.zh-CN.md +116 -192
  8. package/dist/api-automation.html +110 -0
  9. package/dist/api-docs.html +18 -18
  10. package/dist/assets/index-BzRaZegN.css +32 -0
  11. package/dist/assets/{index-CyxRiNt0.js → index-OkHfhUMk.js} +176 -175
  12. package/dist/docs.html +294 -0
  13. package/dist/features.html +112 -0
  14. package/dist/humans.txt +15 -0
  15. package/dist/index.html +2 -2
  16. package/dist/landing.html +217 -0
  17. package/dist/llms-full.txt +117 -0
  18. package/dist/llms.txt +53 -0
  19. package/dist/openapi.yaml +12 -9
  20. package/dist/orchestration.html +125 -0
  21. package/dist/robots.txt +4 -0
  22. package/dist/site.css +536 -0
  23. package/dist/sitemap.xml +51 -0
  24. package/dist-server/server/cli.js +51 -2
  25. package/dist-server/server/cli.js.map +1 -1
  26. package/dist-server/server/daemon/manager.js +0 -1
  27. package/dist-server/server/daemon/manager.js.map +1 -1
  28. package/dist-server/server/database/db.js +3 -2
  29. package/dist-server/server/database/db.js.map +1 -1
  30. package/dist-server/server/middleware/auth.js +9 -8
  31. package/dist-server/server/middleware/auth.js.map +1 -1
  32. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +156 -32
  33. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
  34. package/dist-server/server/modules/providers/provider.routes.js +8 -1
  35. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  36. package/dist-server/server/routes/agent.js +72 -11
  37. package/dist-server/server/routes/agent.js.map +1 -1
  38. package/dist-server/server/services/notification-orchestrator.js +11 -2
  39. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  40. package/dist-server/server/services/provider-cli-versions.js +142 -0
  41. package/dist-server/server/services/provider-cli-versions.js.map +1 -0
  42. package/dist-server/server/services/startup-update.js +208 -0
  43. package/dist-server/server/services/startup-update.js.map +1 -0
  44. package/package.json +35 -10
  45. package/server/cli.js +58 -3
  46. package/server/daemon/manager.js +0 -1
  47. package/server/database/db.js +3 -2
  48. package/server/middleware/auth.js +9 -8
  49. package/server/modules/orchestration/workflows/workflow-runner.ts +172 -32
  50. package/server/modules/providers/provider.routes.ts +8 -1
  51. package/server/routes/agent.js +75 -10
  52. package/server/services/notification-orchestrator.js +11 -2
  53. package/server/services/provider-cli-versions.js +149 -0
  54. package/server/services/startup-update.js +234 -0
  55. package/dist/assets/index-BwmhA_le.css +0 -32
@@ -896,6 +896,124 @@ class WorkflowRunner {
896
896
  }
897
897
  }
898
898
 
899
+ private fallbackAgentFor(run: WorkflowRun, node: WorkflowNode): AgentAssignment | undefined {
900
+ if (node.stage === 'fallback' || node.id.startsWith('fallback_')) {
901
+ return undefined;
902
+ }
903
+
904
+ const settings = getMetadataRecord(run.metadata, 'settings');
905
+ const fallbackAgentInstanceId = readString(settings.fallbackAgentInstanceId);
906
+ if (!fallbackAgentInstanceId || fallbackAgentInstanceId === node.agentInstanceId) {
907
+ return undefined;
908
+ }
909
+
910
+ return readAgentAssignments(run.metadata).find((agent) => agent.instanceId === fallbackAgentInstanceId);
911
+ }
912
+
913
+ private createFallbackNode(node: WorkflowNode, fallbackAgent: AgentAssignment, reason: string): WorkflowNode {
914
+ const fallbackSuffix = safeNodeId(fallbackAgent.instanceId, 'fallback');
915
+ return {
916
+ ...node,
917
+ id: `fallback_${node.id}_${fallbackSuffix}`,
918
+ adapterId: fallbackAgent.adapterId,
919
+ agentInstanceId: fallbackAgent.instanceId,
920
+ agentLabel: `${fallbackAgent.label} Fallback`,
921
+ assignment: `Fallback for ${node.agentLabel || node.id}`,
922
+ stage: 'fallback',
923
+ model: fallbackAgent.model,
924
+ permissionMode: fallbackAgent.permissionMode,
925
+ toolsSettings: fallbackAgent.toolsSettings,
926
+ prompt: [
927
+ 'The previous CLI agent failed on this orchestration step.',
928
+ `Failed step: ${node.agentLabel || node.id}`,
929
+ `Failure: ${reason}`,
930
+ 'Take over the same assignment as the backup CLI. Use the original goal and upstream context.',
931
+ 'Do not repeat unrelated work; complete the failed step and report what you did.',
932
+ node.prompt,
933
+ ].join('\n'),
934
+ onFail: 'continue',
935
+ };
936
+ }
937
+
938
+ private async runFallbackAfterFailure(
939
+ node: WorkflowNode,
940
+ workflow: Workflow,
941
+ run: WorkflowRun,
942
+ outputs: Map<string, string>,
943
+ started: Set<string>,
944
+ completed: Set<string>,
945
+ reason: string,
946
+ ): Promise<boolean> {
947
+ const fallbackAgent = this.fallbackAgentFor(run, node);
948
+ if (!fallbackAgent) {
949
+ return false;
950
+ }
951
+ if (workflow.nodes.length + 1 > 64) {
952
+ run.metadata = {
953
+ ...run.metadata,
954
+ fallbackSkipped: `Workflow node limit reached after ${node.id}.`,
955
+ };
956
+ workflowStore.setRun(run);
957
+ return false;
958
+ }
959
+
960
+ let fallbackNode = this.createFallbackNode(node, fallbackAgent, reason);
961
+ let collision = 1;
962
+ while (workflow.nodes.some((candidate) => candidate.id === fallbackNode.id)) {
963
+ collision += 1;
964
+ fallbackNode = {
965
+ ...fallbackNode,
966
+ id: `${fallbackNode.id}_${collision}`,
967
+ };
968
+ }
969
+
970
+ const nodeIndex = workflow.nodes.findIndex((candidate) => candidate.id === node.id);
971
+ const runIndex = run.nodeRuns.findIndex((candidate) => candidate.nodeId === node.id);
972
+ if (nodeIndex >= 0) {
973
+ workflow.nodes.splice(nodeIndex + 1, 0, fallbackNode);
974
+ } else {
975
+ workflow.nodes.push(fallbackNode);
976
+ }
977
+ if (runIndex >= 0) {
978
+ run.nodeRuns.splice(runIndex + 1, 0, nodeRunFromNode(fallbackNode));
979
+ } else {
980
+ run.nodeRuns.push(nodeRunFromNode(fallbackNode));
981
+ }
982
+
983
+ const fallbackEvents = Array.isArray(run.metadata?.fallbackEvents)
984
+ ? run.metadata.fallbackEvents
985
+ : [];
986
+ run.metadata = {
987
+ ...run.metadata,
988
+ fallbackEvents: [
989
+ ...fallbackEvents,
990
+ {
991
+ nodeId: node.id,
992
+ fallbackNodeId: fallbackNode.id,
993
+ fallbackAgentInstanceId: fallbackAgent.instanceId,
994
+ reason,
995
+ startedAt: Date.now(),
996
+ },
997
+ ],
998
+ };
999
+ workflowStore.setRun(run);
1000
+
1001
+ await this.executeNode(fallbackNode, workflow, run, outputs, started, completed);
1002
+
1003
+ const fallbackRun = run.nodeRuns.find((candidate) => candidate.nodeId === fallbackNode.id);
1004
+ if (fallbackRun?.status !== 'completed') {
1005
+ return false;
1006
+ }
1007
+
1008
+ const fallbackOutput = outputs.get(fallbackNode.id) || fallbackRun.outputText;
1009
+ if (fallbackOutput) {
1010
+ outputs.set(node.id, compactOutputForContext(fallbackOutput));
1011
+ }
1012
+ completed.add(node.id);
1013
+ workflowStore.setRun(run);
1014
+ return true;
1015
+ }
1016
+
899
1017
  private maybeAddRepairCycle(
900
1018
  node: WorkflowNode,
901
1019
  workflow: Workflow,
@@ -1087,40 +1205,56 @@ class WorkflowRunner {
1087
1205
  const isolation = readIsolation(settings.isolation) ?? node.isolation ?? 'host';
1088
1206
  const keepAfterCompletion = readBoolean(settings.keepWorkspace) ?? true;
1089
1207
  const baseRef = readString(settings.baseRef) ?? 'HEAD';
1090
- const submit = await fetch(`${localA2ABaseUrl()}/tasks`, {
1091
- method: 'POST',
1092
- headers: { 'content-type': 'application/json' },
1093
- body: JSON.stringify({
1094
- adapterId: node.adapterId,
1095
- contextId: run.contextId,
1096
- message: {
1097
- messageId: newId('msg'),
1098
- role: 'user',
1099
- parts: [{ kind: 'text', text: prompt }],
1100
- },
1101
- metadata: {
1102
- workflowRunId: run.id,
1103
- workflowNodeId: node.id,
1104
- agentInstanceId: node.agentInstanceId,
1105
- agentLabel: node.agentLabel,
1106
- assignment: node.assignment,
1107
- model: node.model,
1108
- permissionMode: node.permissionMode,
1109
- toolsSettings: node.toolsSettings,
1110
- projectPath,
1111
- workspaceTarget: workspaceTargetMetadata(workspaceTarget),
1112
- workspace: {
1113
- kind: isolation,
1208
+ let body: { id?: string; error?: { message?: string } };
1209
+ try {
1210
+ const submit = await fetch(`${localA2ABaseUrl()}/tasks`, {
1211
+ method: 'POST',
1212
+ headers: { 'content-type': 'application/json' },
1213
+ body: JSON.stringify({
1214
+ adapterId: node.adapterId,
1215
+ contextId: run.contextId,
1216
+ message: {
1217
+ messageId: newId('msg'),
1218
+ role: 'user',
1219
+ parts: [{ kind: 'text', text: prompt }],
1220
+ },
1221
+ metadata: {
1222
+ workflowRunId: run.id,
1223
+ workflowNodeId: node.id,
1224
+ agentInstanceId: node.agentInstanceId,
1225
+ agentLabel: node.agentLabel,
1226
+ assignment: node.assignment,
1227
+ model: node.model,
1228
+ permissionMode: node.permissionMode,
1229
+ toolsSettings: node.toolsSettings,
1114
1230
  projectPath,
1115
- baseRef,
1116
- keepAfterCompletion,
1231
+ workspaceTarget: workspaceTargetMetadata(workspaceTarget),
1232
+ workspace: {
1233
+ kind: isolation,
1234
+ projectPath,
1235
+ baseRef,
1236
+ keepAfterCompletion,
1237
+ },
1117
1238
  },
1118
- },
1119
- }),
1120
- });
1121
- const body = await submit.json() as { id?: string; error?: { message?: string } };
1122
- if (!submit.ok || !body.id) {
1123
- throw new Error(body.error?.message ?? `Workflow node ${node.id} submit failed.`);
1239
+ }),
1240
+ });
1241
+ body = await submit.json() as { id?: string; error?: { message?: string } };
1242
+ if (!submit.ok || !body.id) {
1243
+ throw new Error(body.error?.message ?? `Workflow node ${node.id} submit failed.`);
1244
+ }
1245
+ } catch (error) {
1246
+ nodeRun.finishedAt = Date.now();
1247
+ nodeRun.status = 'failed';
1248
+ nodeRun.error = error instanceof Error ? error.message : String(error);
1249
+ workflowStore.setRun(run);
1250
+ if (await this.runFallbackAfterFailure(node, workflow, run, outputs, started, completed, nodeRun.error)) {
1251
+ return;
1252
+ }
1253
+ if (node.onFail === 'continue') {
1254
+ completed.add(node.id);
1255
+ return;
1256
+ }
1257
+ throw error;
1124
1258
  }
1125
1259
  nodeRun.a2aTaskId = body.id;
1126
1260
  workflowStore.setRun(run);
@@ -1160,6 +1294,9 @@ class WorkflowRunner {
1160
1294
  outputs.set(node.id, compactOutputForContext(nodeRun.outputText));
1161
1295
  }
1162
1296
  workflowStore.setRun(run);
1297
+ if (await this.runFallbackAfterFailure(node, workflow, run, outputs, started, completed, nodeRun.error)) {
1298
+ return;
1299
+ }
1163
1300
  if (node.onFail === 'continue') {
1164
1301
  completed.add(node.id);
1165
1302
  return;
@@ -1192,6 +1329,9 @@ class WorkflowRunner {
1192
1329
  nodeRun.status = 'failed';
1193
1330
  nodeRun.error = result.error ?? `A2A task ended with ${result.state}`;
1194
1331
  workflowStore.setRun(run);
1332
+ if (await this.runFallbackAfterFailure(node, workflow, run, outputs, started, completed, nodeRun.error)) {
1333
+ return;
1334
+ }
1195
1335
  if (node.onFail === 'continue') {
1196
1336
  if (nodeRun.outputText) {
1197
1337
  outputs.set(node.id, compactOutputForContext(nodeRun.outputText));
@@ -14,6 +14,8 @@ import {
14
14
 
15
15
  // @ts-ignore — plain-JS service
16
16
  import { getProviderModels, clearProviderModelCache } from '@/services/provider-models.js';
17
+ // @ts-ignore — plain-JS service
18
+ import { getProviderCliVersionStatus } from '@/services/provider-cli-versions.js';
17
19
 
18
20
  // @ts-ignore — plain-JS service
19
21
  import {
@@ -262,8 +264,13 @@ router.get(
262
264
  '/:provider/auth/status',
263
265
  asyncHandler(async (req: Request, res: Response) => {
264
266
  const provider = parseProvider(req.params.provider);
267
+ const forceRefresh = String(req.query.refresh || '').toLowerCase() === '1';
265
268
  const status = await providerAuthService.getProviderAuthStatus(provider);
266
- res.json(createApiSuccessResponse(status));
269
+ const cliVersion = await getProviderCliVersionStatus(provider, {
270
+ installed: status.installed,
271
+ forceRefresh,
272
+ });
273
+ res.json(createApiSuccessResponse({ ...status, ...cliVersion }));
267
274
  }),
268
275
  );
269
276
 
@@ -19,6 +19,7 @@ import { CODEX_MODELS } from '../../shared/modelConstants.js';
19
19
  import { IS_PLATFORM } from '../constants/config.js';
20
20
 
21
21
  const router = express.Router();
22
+ const isPixcodeApiKey = (token) => typeof token === 'string' && (token.startsWith('px_') || token.startsWith('ck_'));
22
23
 
23
24
  /**
24
25
  * Middleware to authenticate agent API requests.
@@ -49,18 +50,18 @@ const validateExternalApiKey = (req, res, next) => {
49
50
  }
50
51
 
51
52
  // Self-hosted mode: validate API key from any of the supported transports.
52
- // - Authorization: Bearer ck_... (added so /api/agent accepts the same
53
+ // - Authorization: Bearer px_... (legacy ck_... still accepted)
53
54
  // auth shape as the rest of the API, per the auth-unify in this turn)
54
- // - X-API-Key: ck_... (legacy, kept working)
55
- // - ?apiKey=ck_... (EventSource workaround)
55
+ // - X-API-Key: px_...
56
+ // - ?apiKey=px_... (EventSource workaround)
56
57
  const authHeader = req.headers['authorization'];
57
58
  const bearer = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
58
- const apiKey = (bearer && bearer.startsWith('ck_') ? bearer : null)
59
+ const apiKey = (isPixcodeApiKey(bearer) ? bearer : null)
59
60
  || req.headers['x-api-key']
60
61
  || (typeof req.query.apiKey === 'string' ? req.query.apiKey : null);
61
62
 
62
63
  if (!apiKey) {
63
- return res.status(401).json({ error: 'API key required (Authorization: Bearer ck_..., X-API-Key, or ?apiKey=)' });
64
+ return res.status(401).json({ error: 'API key required (Authorization: Bearer px_..., X-API-Key, or ?apiKey=)' });
64
65
  }
65
66
 
66
67
  const user = apiKeysDb.validateApiKey(apiKey);
@@ -230,6 +231,61 @@ function validateBranchName(branchName) {
230
231
  return { valid: true };
231
232
  }
232
233
 
234
+ function providerDisplayName(provider) {
235
+ return ({
236
+ claude: 'Claude',
237
+ cursor: 'Cursor',
238
+ codex: 'Codex',
239
+ gemini: 'Gemini',
240
+ qwen: 'Qwen',
241
+ opencode: 'OpenCode',
242
+ })[provider] || 'Provider';
243
+ }
244
+
245
+ function describeProviderFailure(rawError, provider) {
246
+ const rawMessage = String(rawError || '').trim() || 'Provider returned no assistant text.';
247
+ const normalized = rawMessage.toLowerCase();
248
+ const name = providerDisplayName(provider);
249
+
250
+ const details = {
251
+ provider,
252
+ providerName: name,
253
+ category: 'provider_error',
254
+ title: `${name} could not answer.`,
255
+ action: 'Check the provider output, then retry with a shorter prompt or a different model.',
256
+ rawMessage,
257
+ };
258
+
259
+ if (/(balance|billing|quota|credit|insufficient|payment required|402|usage limit|spend limit)/i.test(rawMessage)) {
260
+ details.category = 'quota';
261
+ details.title = `${name} could not answer because the account has no available balance or quota.`;
262
+ details.action = 'Add credits, increase the provider usage limit, or switch to a free/available model.';
263
+ } else if (/(rate limit|too many requests|429|temporarily overloaded|resource exhausted)/i.test(rawMessage)) {
264
+ details.category = 'rate_limit';
265
+ details.title = `${name} is rate limited right now.`;
266
+ details.action = 'Wait a bit, reduce parallel runs, or switch to another provider/model.';
267
+ } else if (/(unauthorized|forbidden|permission_denied|permission denied|api key|token|oauth|login|not authenticated|401|403|invalid credentials)/i.test(rawMessage)) {
268
+ details.category = 'auth';
269
+ details.title = `${name} is not authenticated or the selected model is not allowed.`;
270
+ details.action = 'Reconnect this provider in Settings, refresh the CLI login, or choose a model enabled for the account.';
271
+ } else if (/(not installed|command not found|enoent|spawn .* enoent|executable file not found)/i.test(rawMessage)) {
272
+ details.category = 'missing_cli';
273
+ details.title = `${name} CLI is not installed or not on PATH.`;
274
+ details.action = 'Install the CLI from Settings -> Agents or set the matching CLI path environment variable.';
275
+ } else if (/(timeout|timed out|aborted|etimedout|deadline)/i.test(rawMessage)) {
276
+ details.category = 'timeout';
277
+ details.title = `${name} timed out before returning a complete answer.`;
278
+ details.action = 'Retry with a shorter request, reduce orchestration parallelism, or inspect the provider session log.';
279
+ } else if (normalized.includes('no assistant text') || normalized.includes('empty')) {
280
+ details.category = 'no_output';
281
+ details.title = `${name} finished without visible assistant text.`;
282
+ details.action = 'Retry once; if it repeats, check provider stderr/session logs because the CLI may have exited before streaming text.';
283
+ }
284
+
285
+ details.message = `${details.title} ${details.action}`;
286
+ return details;
287
+ }
288
+
233
289
  /**
234
290
  * Get recent commit messages from a repository
235
291
  * @param {string} projectPath - Path to the git repository
@@ -1276,6 +1332,12 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1276
1332
  (m) => m.type === 'assistant' && m.message?.content?.some?.((p) => p.type === 'text' && p.text)
1277
1333
  );
1278
1334
  const succeeded = !errorEntry && (hasAssistantText || assistantMessages.some((m) => m.type === 'tool_use' || m.type === 'tool_result'));
1335
+ const failureDetails = succeeded
1336
+ ? null
1337
+ : describeProviderFailure(
1338
+ errorEntry?.content || 'Provider returned no assistant text. Check backend log for details.',
1339
+ provider,
1340
+ );
1279
1341
 
1280
1342
  const response = {
1281
1343
  success: succeeded,
@@ -1284,10 +1346,10 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1284
1346
  tokens: tokenSummary,
1285
1347
  projectPath: finalProjectPath
1286
1348
  };
1287
- if (errorEntry) {
1288
- response.error = errorEntry.content;
1289
- } else if (!succeeded) {
1290
- response.error = 'Provider returned no assistant text. Check backend log for details.';
1349
+ if (failureDetails) {
1350
+ response.error = failureDetails.message;
1351
+ response.rawError = failureDetails.rawMessage;
1352
+ response.errorDetails = failureDetails;
1291
1353
  }
1292
1354
 
1293
1355
  // Add branch/PR info if created
@@ -1353,10 +1415,13 @@ router.post('/', validateExternalApiKey, async (req, res) => {
1353
1415
  if (errEntry) collectedError = errEntry.content;
1354
1416
  } catch { /* ignore — fall back to error.message */ }
1355
1417
  }
1418
+ const failureDetails = describeProviderFailure(collectedError || error.message, provider);
1356
1419
  res.status(502).json({
1357
1420
  success: false,
1358
1421
  sessionId: writer && typeof writer.getSessionId === 'function' ? writer.getSessionId() : null,
1359
- error: collectedError || error.message,
1422
+ error: failureDetails.message,
1423
+ rawError: failureDetails.rawMessage,
1424
+ errorDetails: failureDetails,
1360
1425
  wrapperError: collectedError ? error.message : undefined,
1361
1426
  messages: collectedMessages,
1362
1427
  });
@@ -7,7 +7,8 @@ import { notifyUser as notifyTelegramUser } from './telegram/bot.js';
7
7
  const KIND_TO_PREF_KEY = {
8
8
  action_required: 'actionRequired',
9
9
  stop: 'stop',
10
- error: 'error'
10
+ error: 'error',
11
+ update: 'updates'
11
12
  };
12
13
 
13
14
  const PROVIDER_LABELS = {
@@ -15,6 +16,8 @@ const PROVIDER_LABELS = {
15
16
  cursor: 'Cursor',
16
17
  codex: 'Codex',
17
18
  gemini: 'Gemini',
19
+ qwen: 'Qwen Code',
20
+ opencode: 'OpenCode',
18
21
  system: 'System'
19
22
  };
20
23
 
@@ -121,7 +124,13 @@ function buildPushBody(event) {
121
124
  'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
122
125
  'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
123
126
  'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
124
- 'push.enabled': 'Push notifications are now enabled!'
127
+ 'push.enabled': 'Push notifications are now enabled!',
128
+ 'app.update.available': event.meta?.latestVersion
129
+ ? `Pixcode ${event.meta.latestVersion} is available`
130
+ : 'A Pixcode update is available',
131
+ 'cli.update.available': event.meta?.latestVersion
132
+ ? `CLI update available: ${event.meta.latestVersion}`
133
+ : 'A CLI update is available'
125
134
  };
126
135
  const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant';
127
136
  const sessionName = resolveSessionName(event);
@@ -0,0 +1,149 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
5
+ const cache = new Map();
6
+ const inflight = new Map();
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ const providerConfigs = {
10
+ claude: {
11
+ command: () => process.env.CLAUDE_CLI_PATH || 'claude',
12
+ args: ['--version'],
13
+ packageName: '@anthropic-ai/claude-code',
14
+ },
15
+ cursor: {
16
+ command: () => process.env.CURSOR_CLI_PATH || 'cursor-agent',
17
+ args: ['--version'],
18
+ packageName: null,
19
+ },
20
+ codex: {
21
+ command: () => process.env.CODEX_CLI_PATH || 'codex',
22
+ args: ['--version'],
23
+ packageName: '@openai/codex',
24
+ },
25
+ gemini: {
26
+ command: () => process.env.GEMINI_CLI_PATH || 'gemini',
27
+ args: ['--version'],
28
+ packageName: '@google/gemini-cli',
29
+ },
30
+ qwen: {
31
+ command: () => process.env.QWEN_CLI_PATH || 'qwen',
32
+ args: ['--version'],
33
+ packageName: '@qwen-code/qwen-code',
34
+ },
35
+ opencode: {
36
+ command: () => process.env.OPENCODE_CLI_PATH || 'opencode',
37
+ args: ['--version'],
38
+ packageName: 'opencode-ai',
39
+ },
40
+ };
41
+
42
+ function normalizeVersion(value) {
43
+ const match = String(value || '').match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
44
+ return match?.[0] || null;
45
+ }
46
+
47
+ function compareVersions(left, right) {
48
+ const a = String(left || '0.0.0').replace(/^v/, '').split(/[.+-]/).slice(0, 3).map(Number);
49
+ const b = String(right || '0.0.0').replace(/^v/, '').split(/[.+-]/).slice(0, 3).map(Number);
50
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
51
+ const av = Number.isFinite(a[index]) ? a[index] : 0;
52
+ const bv = Number.isFinite(b[index]) ? b[index] : 0;
53
+ if (av !== bv) return av - bv;
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ async function readInstalledVersion(config) {
59
+ try {
60
+ const result = await execFileAsync(config.command(), config.args, {
61
+ encoding: 'utf8',
62
+ timeout: 5000,
63
+ windowsHide: true,
64
+ maxBuffer: 64 * 1024,
65
+ });
66
+ return normalizeVersion(`${result.stdout || ''}\n${result.stderr || ''}`);
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ async function readLatestVersion(packageName) {
73
+ if (!packageName) return null;
74
+ let result;
75
+ try {
76
+ result = await execFileAsync('npm', ['view', packageName, 'version', '--json'], {
77
+ encoding: 'utf8',
78
+ timeout: 7000,
79
+ windowsHide: true,
80
+ maxBuffer: 64 * 1024,
81
+ });
82
+ } catch {
83
+ return null;
84
+ }
85
+
86
+ try {
87
+ const parsed = JSON.parse(result.stdout);
88
+ return normalizeVersion(parsed);
89
+ } catch {
90
+ return normalizeVersion(result.stdout);
91
+ }
92
+ }
93
+
94
+ function getSkipReason(config, installedVersion, latestVersion) {
95
+ if (!config.packageName) return 'external_installer';
96
+ if (!installedVersion) return 'installed_version_unavailable';
97
+ if (!latestVersion) return 'latest_version_unavailable';
98
+ return null;
99
+ }
100
+
101
+ async function resolveProviderCliVersionStatus(provider, config, now) {
102
+ const installedVersion = await readInstalledVersion(config);
103
+ const latestVersion = await readLatestVersion(config.packageName);
104
+ const updateAvailable = Boolean(
105
+ installedVersion
106
+ && latestVersion
107
+ && compareVersions(latestVersion, installedVersion) > 0,
108
+ );
109
+
110
+ const payload = {
111
+ checkedAt: new Date(now).toISOString(),
112
+ installedVersion,
113
+ latestVersion,
114
+ updateAvailable,
115
+ versionCheckSkipped: getSkipReason(config, installedVersion, latestVersion),
116
+ };
117
+ cache.set(provider, { checkedAtMs: now, payload });
118
+ return payload;
119
+ }
120
+
121
+ export async function getProviderCliVersionStatus(provider, { installed = true, forceRefresh = false } = {}) {
122
+ const config = providerConfigs[provider];
123
+ if (!config || !installed) {
124
+ return {
125
+ checkedAt: new Date().toISOString(),
126
+ installedVersion: null,
127
+ latestVersion: null,
128
+ updateAvailable: false,
129
+ versionCheckSkipped: !config ? 'unsupported_provider' : 'not_installed',
130
+ };
131
+ }
132
+
133
+ const now = Date.now();
134
+ const cached = cache.get(provider);
135
+ if (!forceRefresh && cached && now - cached.checkedAtMs < ONE_DAY_MS) {
136
+ return { ...cached.payload, fromCache: true };
137
+ }
138
+
139
+ const inflightKey = provider;
140
+ if (!forceRefresh && inflight.has(inflightKey)) {
141
+ return inflight.get(inflightKey);
142
+ }
143
+
144
+ const promise = resolveProviderCliVersionStatus(provider, config, now).finally(() => {
145
+ inflight.delete(inflightKey);
146
+ });
147
+ inflight.set(inflightKey, promise);
148
+ return promise;
149
+ }